summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/blog/[slug]/page.tsx2
-rw-r--r--apps/web/src/app/blog/page.tsx2
-rw-r--r--apps/web/src/app/globals.css438
-rw-r--r--apps/web/src/app/layout.tsx19
-rw-r--r--apps/web/src/app/privacy/page.tsx121
-rw-r--r--apps/web/src/app/projects/page.tsx20
-rw-r--r--apps/web/src/app/projects/store.ts2
-rw-r--r--apps/web/src/app/terms/page.tsx100
-rw-r--r--apps/web/src/components/editable-timecode.tsx26
-rw-r--r--apps/web/src/components/editor/dialogs/project-info-dialog.tsx2
-rw-r--r--apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx2
-rw-r--r--apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx41
-rw-r--r--apps/web/src/components/editor/editor-header.tsx7
-rw-r--r--apps/web/src/components/editor/export-button.tsx63
-rw-r--r--apps/web/src/components/editor/mobile-gate.tsx6
-rw-r--r--apps/web/src/components/editor/panels/assets/drag-overlay.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx17
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx15
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx153
-rw-r--r--apps/web/src/components/editor/panels/assets/views/base-panel.tsx (renamed from apps/web/src/components/editor/panels/assets/views/base-view.tsx)11
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx141
-rw-r--r--apps/web/src/components/editor/panels/assets/views/effects.tsx15
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx332
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings.tsx168
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/background.tsx227
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/index.tsx369
-rw-r--r--apps/web/src/components/editor/panels/assets/views/sounds.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/views/stickers.tsx538
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx12
-rw-r--r--apps/web/src/components/editor/panels/preview/context-menu.tsx39
-rw-r--r--apps/web/src/components/editor/panels/preview/guide-overlay.tsx32
-rw-r--r--apps/web/src/components/editor/panels/preview/guide-popover.tsx100
-rw-r--r--apps/web/src/components/editor/panels/preview/handle-primitives.tsx332
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx266
-rw-r--r--apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx27
-rw-r--r--apps/web/src/components/editor/panels/preview/mask-handles.tsx238
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx109
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-viewport.tsx576
-rw-r--r--apps/web/src/components/editor/panels/preview/snap-guides.tsx30
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx149
-rw-r--r--apps/web/src/components/editor/panels/preview/toolbar.tsx169
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx273
-rw-r--r--apps/web/src/components/editor/panels/properties/audio-properties.tsx3
-rw-r--r--apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx215
-rw-r--r--apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx (renamed from apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx)0
-rw-r--r--apps/web/src/components/editor/panels/properties/components/property-param-field.tsx194
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-param-field.tsx152
-rw-r--r--apps/web/src/components/editor/panels/properties/effect-properties.tsx58
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts4
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts26
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts148
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts190
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx141
-rw-r--r--apps/web/src/components/editor/panels/properties/registry.tsx303
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx382
-rw-r--r--apps/web/src/components/editor/panels/properties/stores/properties-store.ts18
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx109
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx (renamed from apps/web/src/components/editor/panels/properties/sections/blending.tsx)196
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx309
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx238
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx660
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx137
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx (renamed from apps/web/src/components/editor/panels/properties/text-properties.tsx)136
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx424
-rw-r--r--apps/web/src/components/editor/panels/properties/video-properties.tsx25
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-waveform.tsx271
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx27
-rw-r--r--apps/web/src/components/editor/panels/timeline/drag-line.tsx5
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx885
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx10
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx470
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx38
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx26
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-tick.tsx4
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx16
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-track.tsx62
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx27
-rw-r--r--apps/web/src/components/section.tsx (renamed from apps/web/src/components/editor/panels/properties/section.tsx)131
-rw-r--r--apps/web/src/components/ui/button.tsx2
-rw-r--r--apps/web/src/components/ui/color-picker.tsx13
-rw-r--r--apps/web/src/components/ui/context-menu.tsx14
-rw-r--r--apps/web/src/components/ui/dialog.tsx16
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx23
-rw-r--r--apps/web/src/components/ui/font-picker.tsx107
-rw-r--r--apps/web/src/components/ui/input-with-back.tsx85
-rw-r--r--apps/web/src/components/ui/input.tsx7
-rw-r--r--apps/web/src/components/ui/number-field.tsx223
-rw-r--r--apps/web/src/components/ui/popover.tsx20
-rw-r--r--apps/web/src/components/ui/select.tsx16
-rw-r--r--apps/web/src/components/ui/sheet.tsx17
-rw-r--r--apps/web/src/components/ui/sonner.tsx2
-rw-r--r--apps/web/src/components/ui/tabs.tsx92
-rw-r--r--apps/web/src/components/ui/tooltip.tsx4
-rw-r--r--apps/web/src/components/ui/use-overlay-open-change.ts78
-rw-r--r--apps/web/src/constants/editor-constants.ts14
-rw-r--r--apps/web/src/constants/export-constants.ts2
-rw-r--r--apps/web/src/constants/font-constants.ts2
-rw-r--r--apps/web/src/constants/guide-constants.ts4
-rw-r--r--apps/web/src/constants/language-constants.ts3
-rw-r--r--apps/web/src/constants/mask-constants.ts4
-rw-r--r--apps/web/src/constants/project-constants.ts10
-rw-r--r--apps/web/src/constants/retime-constants.ts30
-rw-r--r--apps/web/src/constants/sticker-constants.ts4
-rw-r--r--apps/web/src/constants/text-constants.ts42
-rw-r--r--apps/web/src/constants/timeline-constants.ts85
-rw-r--r--apps/web/src/constants/timeline-constants.tsx102
-rw-r--r--apps/web/src/constants/transcription-constants.ts4
-rw-r--r--apps/web/src/core/index.ts7
-rw-r--r--apps/web/src/core/managers/audio-manager.ts352
-rw-r--r--apps/web/src/core/managers/media-manager.ts73
-rw-r--r--apps/web/src/core/managers/playback-manager.ts15
-rw-r--r--apps/web/src/core/managers/project-manager.ts49
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts60
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts6
-rw-r--r--apps/web/src/core/managers/selection-manager.ts9
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts155
-rw-r--r--apps/web/src/data/colors/syntax-ui.tsx2
-rw-r--r--apps/web/src/hooks/actions/use-editor-actions.ts89
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts83
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts231
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-selection.ts35
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-drag.ts18
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts2
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts2
-rw-r--r--apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts42
-rw-r--r--apps/web/src/hooks/timeline/use-selection-box.ts255
-rw-r--r--apps/web/src/hooks/timeline/use-snap-indicator-position.ts14
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts223
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts178
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts28
-rw-r--r--apps/web/src/hooks/use-container-size.ts24
-rw-r--r--apps/web/src/hooks/use-editor.ts63
-rw-r--r--apps/web/src/hooks/use-effect-preview.ts47
-rw-r--r--apps/web/src/hooks/use-element-preview.ts38
-rw-r--r--apps/web/src/hooks/use-file-upload.ts12
-rw-r--r--apps/web/src/hooks/use-font-atlas.ts53
-rw-r--r--apps/web/src/hooks/use-keybindings.ts163
-rw-r--r--apps/web/src/hooks/use-mask-handles.ts258
-rw-r--r--apps/web/src/hooks/use-menu-preview.ts26
-rw-r--r--apps/web/src/hooks/use-paste-media.ts81
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts345
-rw-r--r--apps/web/src/hooks/use-resize-observer.ts26
-rw-r--r--apps/web/src/hooks/use-reveal-item.ts36
-rw-r--r--apps/web/src/hooks/use-storage-persistence.ts45
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts455
-rw-r--r--apps/web/src/lib/actions/definitions.ts27
-rw-r--r--apps/web/src/lib/actions/keybinding.ts (renamed from apps/web/src/types/keybinding.ts)2
-rw-r--r--apps/web/src/lib/actions/registry.ts27
-rw-r--r--apps/web/src/lib/actions/types.ts2
-rw-r--r--apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts313
-rw-r--r--apps/web/src/lib/animation/color-channel.ts2
-rw-r--r--apps/web/src/lib/animation/effect-param-channel.ts55
-rw-r--r--apps/web/src/lib/animation/graphic-param-channel.ts79
-rw-r--r--apps/web/src/lib/animation/index.ts32
-rw-r--r--apps/web/src/lib/animation/interpolation.ts71
-rw-r--r--apps/web/src/lib/animation/keyframe-query.ts17
-rw-r--r--apps/web/src/lib/animation/keyframes.ts231
-rw-r--r--apps/web/src/lib/animation/number-channel.ts2
-rw-r--r--apps/web/src/lib/animation/property-groups.ts39
-rw-r--r--apps/web/src/lib/animation/property-registry.ts93
-rw-r--r--apps/web/src/lib/animation/resolve.ts69
-rw-r--r--apps/web/src/lib/animation/target-resolver.ts279
-rw-r--r--apps/web/src/lib/animation/types.ts (renamed from apps/web/src/types/animation.ts)40
-rw-r--r--apps/web/src/lib/animation/vector-channel.ts72
-rw-r--r--apps/web/src/lib/blog/query.ts2
-rw-r--r--apps/web/src/lib/blog/types.ts (renamed from apps/web/src/types/blog.ts)0
-rw-r--r--apps/web/src/lib/cancel-interaction.ts24
-rw-r--r--apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts433
-rw-r--r--apps/web/src/lib/commands/index.ts1
-rw-r--r--apps/web/src/lib/commands/media/add-media-asset.ts34
-rw-r--r--apps/web/src/lib/commands/media/remove-media-asset.ts34
-rw-r--r--apps/web/src/lib/commands/project/update-project-settings.ts2
-rw-r--r--apps/web/src/lib/commands/scene/create-scene.ts2
-rw-r--r--apps/web/src/lib/commands/scene/delete-scene.ts2
-rw-r--r--apps/web/src/lib/commands/scene/move-bookmark.ts2
-rw-r--r--apps/web/src/lib/commands/scene/remove-bookmark.ts2
-rw-r--r--apps/web/src/lib/commands/scene/rename-scene.ts2
-rw-r--r--apps/web/src/lib/commands/scene/toggle-bookmark.ts2
-rw-r--r--apps/web/src/lib/commands/scene/update-bookmark.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/add-effect.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts22
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts20
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts45
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts20
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts30
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/index.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts64
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts75
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/retime/index.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts114
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts107
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-duration.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-start-time.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-trim.ts25
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/add-track.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/remove-track.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/tracks-snapshot.ts2
-rw-r--r--apps/web/src/lib/drag-data.ts2
-rw-r--r--apps/web/src/lib/effects/definitions/blur.frag.glsl10
-rw-r--r--apps/web/src/lib/effects/definitions/blur.ts109
-rw-r--r--apps/web/src/lib/effects/definitions/index.ts6
-rw-r--r--apps/web/src/lib/effects/index.ts45
-rw-r--r--apps/web/src/lib/effects/registry.ts33
-rw-r--r--apps/web/src/lib/effects/types.ts42
-rw-r--r--apps/web/src/lib/export.ts33
-rw-r--r--apps/web/src/lib/fonts/google-fonts.ts26
-rw-r--r--apps/web/src/lib/fonts/types.ts (renamed from apps/web/src/types/fonts.ts)0
-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/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/iconify-api.ts241
-rw-r--r--apps/web/src/lib/masks/__tests__/snap.test.ts235
-rw-r--r--apps/web/src/lib/masks/definitions/box-like.ts270
-rw-r--r--apps/web/src/lib/masks/definitions/ellipse.ts72
-rw-r--r--apps/web/src/lib/masks/definitions/index.ts39
-rw-r--r--apps/web/src/lib/masks/definitions/rectangle.ts94
-rw-r--r--apps/web/src/lib/masks/definitions/split.ts347
-rw-r--r--apps/web/src/lib/masks/geometry.ts112
-rw-r--r--apps/web/src/lib/masks/handle-positions.ts382
-rw-r--r--apps/web/src/lib/masks/index.ts18
-rw-r--r--apps/web/src/lib/masks/param-update.ts59
-rw-r--r--apps/web/src/lib/masks/registry.ts70
-rw-r--r--apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl36
-rw-r--r--apps/web/src/lib/masks/snap.ts335
-rw-r--r--apps/web/src/lib/masks/types.ts115
-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.ts249
-rw-r--r--apps/web/src/lib/media/media-utils.ts2
-rw-r--r--apps/web/src/lib/media/mediabunny.ts130
-rw-r--r--apps/web/src/lib/media/processing.ts90
-rw-r--r--apps/web/src/lib/media/types.ts (renamed from apps/web/src/types/assets.ts)0
-rw-r--r--apps/web/src/lib/media/upload-toast.ts51
-rw-r--r--apps/web/src/lib/params.ts50
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts259
-rw-r--r--apps/web/src/lib/preview/hit-test.ts54
-rw-r--r--apps/web/src/lib/preview/preview-coords.ts99
-rw-r--r--apps/web/src/lib/preview/preview-snap.ts293
-rw-r--r--apps/web/src/lib/project/types.ts (renamed from apps/web/src/types/project.ts)4
-rw-r--r--apps/web/src/lib/registry.ts40
-rw-r--r--apps/web/src/lib/rendering.ts (renamed from apps/web/src/types/rendering.ts)3
-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.ts4
-rw-r--r--apps/web/src/lib/retime/presets.ts12
-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/scenes.ts2
-rw-r--r--apps/web/src/lib/selection/constants.ts1
-rw-r--r--apps/web/src/lib/selection/context.tsx52
-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.ts29
-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.tsx (renamed from apps/web/src/components/editor/selection-box.tsx)0
-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/shaders/jfa-init.frag.glsl25
-rw-r--r--apps/web/src/lib/shaders/jfa-step.frag.glsl60
-rw-r--r--apps/web/src/lib/sounds/types.ts (renamed from apps/web/src/types/sounds.ts)0
-rw-r--r--apps/web/src/lib/stickers/__tests__/sticker-id.test.ts14
-rw-r--r--apps/web/src/lib/stickers/index.ts230
-rw-r--r--apps/web/src/lib/stickers/providers/countries-data.ts1819
-rw-r--r--apps/web/src/lib/stickers/providers/emoji.ts119
-rw-r--r--apps/web/src/lib/stickers/providers/flags.ts97
-rw-r--r--apps/web/src/lib/stickers/providers/icons.ts122
-rw-r--r--apps/web/src/lib/stickers/providers/index.ts14
-rw-r--r--apps/web/src/lib/stickers/providers/logos.ts31
-rw-r--r--apps/web/src/lib/stickers/providers/shapes.ts160
-rw-r--r--apps/web/src/lib/stickers/registry.ts33
-rw-r--r--apps/web/src/lib/stickers/resolver.ts8
-rw-r--r--apps/web/src/lib/stickers/types.ts79
-rw-r--r--apps/web/src/lib/text/layout.ts29
-rw-r--r--apps/web/src/lib/text/measure-element.ts132
-rw-r--r--apps/web/src/lib/time.ts6
-rw-r--r--apps/web/src/lib/timeline/audio-constants.ts2
-rw-r--r--apps/web/src/lib/timeline/audio-state.ts100
-rw-r--r--apps/web/src/lib/timeline/bookmarks.ts2
-rw-r--r--apps/web/src/lib/timeline/defaults.ts77
-rw-r--r--apps/web/src/lib/timeline/drag.ts (renamed from apps/web/src/types/drag.ts)12
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts4
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts241
-rw-r--r--apps/web/src/lib/timeline/index.ts4
-rw-r--r--apps/web/src/lib/timeline/ripple-utils.ts2
-rw-r--r--apps/web/src/lib/timeline/selection-hit-testing.ts133
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts2
-rw-r--r--apps/web/src/lib/timeline/track-element-update.ts2
-rw-r--r--apps/web/src/lib/timeline/track-utils.ts29
-rw-r--r--apps/web/src/lib/timeline/types.ts (renamed from apps/web/src/types/timeline.ts)90
-rw-r--r--apps/web/src/lib/transcription/caption.ts2
-rw-r--r--apps/web/src/lib/transcription/types.ts (renamed from apps/web/src/types/transcription.ts)2
-rw-r--r--apps/web/src/services/renderer/effect-preview.ts329
-rw-r--r--apps/web/src/services/renderer/mask-feather.ts54
-rw-r--r--apps/web/src/services/renderer/nodes/blur-background-node.ts149
-rw-r--r--apps/web/src/services/renderer/nodes/composite-effect-node.ts77
-rw-r--r--apps/web/src/services/renderer/nodes/effect-layer-node.ts29
-rw-r--r--apps/web/src/services/renderer/nodes/graphic-node.ts92
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts4
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts20
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts187
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts1
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts225
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts122
-rw-r--r--apps/web/src/services/renderer/scene-exporter.ts2
-rw-r--r--apps/web/src/services/renderer/webgl-effect-renderer.ts73
-rw-r--r--apps/web/src/services/renderer/webgl/jfa.ts194
-rw-r--r--apps/web/src/services/renderer/webgl/webgl-context.ts49
-rw-r--r--apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts35
-rw-r--r--apps/web/src/services/renderer/webgl/webgl-utils.ts (renamed from apps/web/src/services/renderer/webgl-utils.ts)0
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts93
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts132
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts63
-rw-r--r--apps/web/src/services/storage/migrations/index.ts22
-rw-r--r--apps/web/src/services/storage/migrations/transformers/index.ts1
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts13
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts144
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts113
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts196
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts102
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts106
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts67
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts60
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts107
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts105
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts46
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts37
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts21
-rw-r--r--apps/web/src/services/storage/migrations/v10-to-v11.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v11-to-v12.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v12-to-v13.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v13-to-v14.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v14-to-v15.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v15-to-v16.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v16-to-v17.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v17-to-v18.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v18-to-v19.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v9-to-v10.ts16
-rw-r--r--apps/web/src/services/storage/quota.ts145
-rw-r--r--apps/web/src/services/storage/service.ts52
-rw-r--r--apps/web/src/services/storage/types.ts7
-rw-r--r--apps/web/src/services/transcription/service.ts2
-rw-r--r--apps/web/src/services/transcription/worker.ts2
-rw-r--r--apps/web/src/stores/editor-store.ts2
-rw-r--r--apps/web/src/stores/keybindings-store.ts158
-rw-r--r--apps/web/src/stores/keybindings/migrations/index.ts4
-rw-r--r--apps/web/src/stores/keybindings/migrations/v2-to-v3.ts2
-rw-r--r--apps/web/src/stores/keybindings/migrations/v3-to-v4.ts4
-rw-r--r--apps/web/src/stores/keybindings/migrations/v4-to-v5.ts2
-rw-r--r--apps/web/src/stores/keybindings/migrations/v5-to-v6.ts17
-rw-r--r--apps/web/src/stores/preview-store.ts72
-rw-r--r--apps/web/src/stores/sounds-store.ts2
-rw-r--r--apps/web/src/stores/stickers-store.ts177
-rw-r--r--apps/web/src/stores/timeline-store.ts2
-rw-r--r--apps/web/src/types/editor.ts1
-rw-r--r--apps/web/src/types/effects.ts69
-rw-r--r--apps/web/src/types/export.ts31
-rw-r--r--apps/web/src/types/language.ts4
-rw-r--r--apps/web/src/types/soundtouchjs.d.ts14
-rw-r--r--apps/web/src/types/stickers.ts54
-rw-r--r--apps/web/src/types/time.ts1
-rw-r--r--apps/web/src/utils/browser.ts14
-rw-r--r--apps/web/src/utils/math.ts45
396 files changed, 24783 insertions, 8357 deletions
diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx
index e1afc3a8..90afd919 100644
--- a/apps/web/src/app/blog/[slug]/page.tsx
+++ b/apps/web/src/app/blog/[slug]/page.tsx
@@ -5,7 +5,7 @@ import { BasePage } from "@/app/base-page";
import Prose from "@/components/ui/prose";
import { Separator } from "@/components/ui/separator";
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
-import type { Author, Post } from "@/types/blog";
+import type { Author, Post } from "@/lib/blog/types";
type PageProps = {
params: Promise<{ slug: string }>;
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx
index 257a8f23..df0b195d 100644
--- a/apps/web/src/app/blog/page.tsx
+++ b/apps/web/src/app/blog/page.tsx
@@ -3,7 +3,7 @@ import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query";
-import type { Post } from "@/types/blog";
+import type { Post } from "@/lib/blog/types";
export const metadata: Metadata = {
title: "Blog - OpenCut",
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 6d3e984f..21219427 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -8,111 +8,111 @@
@plugin "tailwindcss-animate";
:root {
- --background: hsl(0, 0%, 100%);
- --foreground: hsl(0 0% 11%);
- --card: hsl(0, 0%, 100%);
- --card-foreground: hsl(0 0% 11%);
- --popover: hsl(0, 0%, 100%);
- --popover-hover: hsl(0, 0%, 96%);
- --popover-foreground: hsl(0 0% 2%);
- --primary: #009dff;
- --primary-foreground: hsl(0, 0%, 100%);
- --secondary: hsl(204, 100%, 97%);
- --secondary-border: hsl(204, 100%, 94%);
- --secondary-foreground: hsl(200, 98%, 39%);
- --muted: hsl(0 0% 85.1%);
- --muted-foreground: hsl(0 0% 50%);
- --accent: hsl(0, 0%, 96%);
- --accent-foreground: hsl(0 0% 2%);
- --destructive: hsl(0, 83%, 50%);
- --destructive-foreground: hsl(0, 0%, 100%);
- --constructive: hsl(141, 71%, 48%);
- --constructive-foreground: hsl(0, 0%, 100%);
- --border: hsl(0 0% 91%);
- --input: hsl(0, 0%, 100%);
- --ring: hsl(0, 0%, 55%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar-background: hsl(0 0% 96.1%);
- --sidebar-foreground: hsl(0 0% 2%);
- --sidebar-primary: hsl(0 0% 2%);
- --sidebar-primary-foreground: hsl(0 0% 91%);
- --sidebar-accent: hsl(0 0% 85.1%);
- --sidebar-accent-foreground: hsl(0 0% 2%);
- --sidebar-border: hsl(0 0% 85.1%);
- --sidebar-ring: hsl(0 0% 16.9%);
- --sidebar: hsl(0 0% 98%);
+ --background: hsl(0, 0%, 100%);
+ --foreground: hsl(0 0% 11%);
+ --card: hsl(0, 0%, 100%);
+ --card-foreground: hsl(0 0% 11%);
+ --popover: hsl(0, 0%, 100%);
+ --popover-hover: hsl(0, 0%, 96%);
+ --popover-foreground: hsl(0 0% 2%);
+ --primary: hsl(200, 90%, 52%);
+ --primary-foreground: hsl(0, 0%, 100%);
+ --secondary: hsl(204, 100%, 97%);
+ --secondary-border: hsl(204, 100%, 94%);
+ --secondary-foreground: hsl(200, 98%, 39%);
+ --muted: hsl(0 0% 85.1%);
+ --muted-foreground: hsl(0 0% 50%);
+ --accent: hsl(0, 0%, 96%);
+ --accent-foreground: hsl(0 0% 2%);
+ --destructive: hsl(0, 83%, 50%);
+ --destructive-foreground: hsl(0, 0%, 100%);
+ --constructive: hsl(141, 71%, 48%);
+ --constructive-foreground: hsl(0, 0%, 100%);
+ --border: hsl(0 0% 91%);
+ --input: hsl(0, 0%, 100%);
+ --ring: hsl(0, 0%, 55%);
+ --chart-1: hsl(220 70% 50%);
+ --chart-2: hsl(160 60% 45%);
+ --chart-3: hsl(30 80% 55%);
+ --chart-4: hsl(280 65% 60%);
+ --chart-5: hsl(340 75% 55%);
+ --sidebar-background: hsl(0 0% 96.1%);
+ --sidebar-foreground: hsl(0 0% 2%);
+ --sidebar-primary: hsl(0 0% 2%);
+ --sidebar-primary-foreground: hsl(0 0% 91%);
+ --sidebar-accent: hsl(0 0% 85.1%);
+ --sidebar-accent-foreground: hsl(0 0% 2%);
+ --sidebar-border: hsl(0 0% 85.1%);
+ --sidebar-ring: hsl(0 0% 16.9%);
+ --sidebar: hsl(0 0% 98%);
}
.panel {
- --background: hsl(216 13% 98%);
- --foreground: hsl(0 0% 13%);
- --card: hsl(0, 0%, 98%);
- --card-foreground: hsl(0 0% 13%);
- --primary-foreground: hsl(0, 0%, 98%);
- --secondary: hsl(204, 100%, 95%);
- --secondary-border: hsl(204, 100%, 92%);
- --secondary-foreground: hsl(200, 98%, 37%);
- --muted: hsl(0 0% 83.1%);
- --muted-foreground: hsl(0 0% 48%);
- --accent: hsl(0, 0%, 93%);
- --accent-foreground: hsl(0 0% 5%);
- --destructive-foreground: hsl(0, 0%, 98%);
- --constructive-foreground: hsl(0, 0%, 98%);
- --border: hsl(0 0% 89%);
- --input: hsl(0 0% 93%);
- --ring: hsl(0, 0%, 53%);
+ --background: hsl(210, 20%, 98%);
+ --foreground: hsl(0 0% 13%);
+ --card: hsl(0, 0%, 98%);
+ --card-foreground: hsl(0 0% 13%);
+ --primary-foreground: hsl(0, 0%, 98%);
+ --secondary: hsl(204, 100%, 95%);
+ --secondary-border: hsl(204, 100%, 92%);
+ --secondary-foreground: hsl(200, 98%, 37%);
+ --muted: hsl(0 0% 83.1%);
+ --muted-foreground: hsl(0 0% 48%);
+ --accent: hsl(0, 0%, 93%);
+ --accent-foreground: hsl(0 0% 5%);
+ --destructive-foreground: hsl(0, 0%, 98%);
+ --constructive-foreground: hsl(0, 0%, 98%);
+ --border: hsl(0 0% 87%);
+ --input: hsl(0 0% 93%);
+ --ring: hsl(0, 0%, 53%);
}
.dark {
- --background: hsl(0, 0%, 5%);
- --foreground: hsl(0 0% 87%);
- --card: hsl(0, 0%, 5%);
- --card-foreground: hsl(0 0% 87%);
- --popover: hsl(0, 0%, 5%);
- --popover-hover: hsl(0, 0%, 22%);
- --popover-foreground: hsl(0 0% 95%);
- --secondary: hsl(204, 100%, 12%);
- --secondary-border: hsl(204, 100%, 15%);
- --secondary-foreground: hsl(200, 98%, 61%);
- --muted: hsl(0 0% 20%);
- --accent: hsl(0, 0%, 14%);
- --accent-foreground: hsl(0 0% 95%);
- --border: hsl(0 0% 16%);
- --input: hsl(0 0% 5%);
- --ring: hsl(0, 0%, 50%);
- --sidebar-background: hsl(0 0% 8%);
- --sidebar-foreground: hsl(0 0% 95%);
- --sidebar-primary: hsl(0 0% 95%);
- --sidebar-primary-foreground: hsl(0 0% 15%);
- --sidebar-accent: hsl(0 0% 20%);
- --sidebar-accent-foreground: hsl(0 0% 95%);
- --sidebar-border: hsl(0 0% 20%);
- --sidebar-ring: hsl(0 0% 83.1%);
- --sidebar: hsl(0 0% 6%);
+ --background: hsl(0, 0%, 5%);
+ --foreground: hsl(0 0% 87%);
+ --card: hsl(0, 0%, 5%);
+ --card-foreground: hsl(0 0% 87%);
+ --popover: hsl(0, 0%, 5%);
+ --popover-hover: hsl(0, 0%, 13%);
+ --popover-foreground: hsl(0 0% 95%);
+ --secondary: hsl(204, 100%, 12%);
+ --secondary-border: hsl(204, 100%, 15%);
+ --secondary-foreground: hsl(200, 98%, 61%);
+ --muted: hsl(0 0% 20%);
+ --accent: hsl(0, 0%, 14%);
+ --accent-foreground: hsl(0 0% 95%);
+ --border: hsl(0 0% 16%);
+ --input: hsl(0 0% 5%);
+ --ring: hsl(0, 0%, 50%);
+ --sidebar-background: hsl(0 0% 8%);
+ --sidebar-foreground: hsl(0 0% 95%);
+ --sidebar-primary: hsl(0 0% 95%);
+ --sidebar-primary-foreground: hsl(0 0% 15%);
+ --sidebar-accent: hsl(0 0% 20%);
+ --sidebar-accent-foreground: hsl(0 0% 95%);
+ --sidebar-border: hsl(0 0% 20%);
+ --sidebar-ring: hsl(0 0% 83.1%);
+ --sidebar: hsl(0 0% 6%);
}
.dark .panel {
- --background: hsl(0 0% 10%);
- --foreground: hsl(0 0% 85%);
- --card: hsl(0, 0%, 10%);
- --card-foreground: hsl(0 0% 85%);
- --secondary: hsl(204, 67%, 9%);
- --secondary-border: hsl(204, 100%, 14%);
- --secondary-foreground: hsl(200, 98%, 63%);
- --muted: hsl(0 0% 22%);
- --accent: hsl(0, 0%, 15%);
- --accent-foreground: hsl(0 0% 93%);
- --border: hsl(0 0% 18%);
- --input: hsl(0 0% 22%);
- --ring: hsl(0, 0%, 52%);
+ --background: hsl(0 0% 10%);
+ --foreground: hsl(0 0% 85%);
+ --card: hsl(0, 0%, 10%);
+ --card-foreground: hsl(0 0% 85%);
+ --secondary: hsl(204, 67%, 9%);
+ --secondary-border: hsl(204, 100%, 14%);
+ --secondary-foreground: hsl(200, 98%, 63%);
+ --muted: hsl(0 0% 22%);
+ --accent: hsl(0, 0%, 15%);
+ --accent-foreground: hsl(0 0% 93%);
+ --border: hsl(0 0% 18%);
+ --input: hsl(0 0% 22%);
+ --ring: hsl(0, 0%, 52%);
}
@layer base {
- /*
+ /*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
@@ -120,165 +120,165 @@
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
- *,
- ::after,
- ::before,
- ::backdrop,
- ::file-selector-button {
- border-color: var(--color-gray-200, currentcolor);
- }
- /* Other default base styles */
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- /* Prevent back/forward swipe */
- overscroll-behavior-x: contain;
- }
- ::selection {
- @apply bg-primary/35 selection:text-primary-foreground;
- }
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+ /* Other default base styles */
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ /* Prevent back/forward swipe */
+ overscroll-behavior-x: contain;
+ }
+ ::selection {
+ @apply bg-primary/35 selection:text-primary-foreground;
+ }
}
@theme inline {
- /* Responsive breakpoints */
- --breakpoint-xs: 30rem;
+ /* Responsive breakpoints */
+ --breakpoint-xs: 30rem;
- /* Typography */
- --font-sans: var(--font-inter), sans-serif;
+ /* Typography */
+ --font-sans: var(--font-inter), sans-serif;
- /* Font sizes */
- --text-xs: 0.72rem;
- --text-sm: 0.79rem;
- --text-base: 0.92rem;
- --text-base--line-height: calc(1.5 / 0.95);
- --text-xs--line-height: calc(1 / 0.8);
+ /* Font sizes */
+ --text-xs: 0.72rem;
+ --text-sm: 0.79rem;
+ --text-base: 0.92rem;
+ --text-base--line-height: calc(1.5 / 0.95);
+ --text-xs--line-height: calc(1 / 0.8);
- /* Border radius */
- --radius-lg: 0.82rem;
- --radius-md: 0.65rem;
- --radius-sm: 0.35rem;
+ /* Border radius */
+ --radius-lg: 0.82rem;
+ --radius-md: 0.65rem;
+ --radius-sm: 0.35rem;
- /* Palette mapped to root design tokens */
- --color-background: var(--background);
- --color-foreground: var(--foreground);
+ /* Palette mapped to root design tokens */
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
- --color-card: var(--card);
- --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
- --color-popover: var(--popover);
- --color-popover-hover: var(--popover-hover);
- --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-popover-hover: var(--popover-hover);
+ --color-popover-foreground: var(--popover-foreground);
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
- --color-secondary: var(--secondary);
- --color-secondary-border: var(--secondary-border);
- --color-secondary-foreground: var(--secondary-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-border: var(--secondary-border);
+ --color-secondary-foreground: var(--secondary-foreground);
- --color-muted: var(--muted);
- --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
- --color-accent: var(--accent);
- --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
- --color-destructive: var(--destructive);
- --color-destructive-foreground: var(--destructive-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
- --color-constructive: var(--constructive);
- --color-constructive-foreground: var(--constructive-foreground);
+ --color-constructive: var(--constructive);
+ --color-constructive-foreground: var(--constructive-foreground);
- --color-border: var(--border);
- --color-input: var(--input);
- --color-ring: var(--ring);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
- /* Chart colors */
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
+ /* Chart colors */
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
- /* Sidebar */
- --color-sidebar: var(--sidebar-background);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
+ /* Sidebar */
+ --color-sidebar: var(--sidebar-background);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
- /* Animations */
- --animate-accordion-down: accordion-down 0.2s ease-out;
- --animate-accordion-up: accordion-up 0.2s ease-out;
+ /* Animations */
+ --animate-accordion-down: accordion-down 0.2s ease-out;
+ --animate-accordion-up: accordion-up 0.2s ease-out;
- @keyframes accordion-down {
- from {
- height: 0;
- }
- to {
- height: var(--radix-accordion-content-height);
- }
- }
+ @keyframes accordion-down {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+ }
- @keyframes accordion-up {
- from {
- height: var(--radix-accordion-content-height);
- }
- to {
- height: 0;
- }
- }
+ @keyframes accordion-up {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+ }
}
@utility scrollbar-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
@utility scrollbar-x-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:horizontal {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:horizontal {
+ display: none;
+ }
}
@utility scrollbar-y-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:vertical {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:vertical {
+ display: none;
+ }
}
@utility scrollbar-thin {
- &::-webkit-scrollbar {
- width: 6px;
- height: 8px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- &::-webkit-scrollbar-thumb {
- background: var(--border);
- border-radius: 4px;
- }
- &::-webkit-scrollbar-thumb:hover {
- background: var(--muted-foreground);
- }
+ &::-webkit-scrollbar {
+ width: 7px;
+ height: 7px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+ }
+ &::-webkit-scrollbar-thumb:hover {
+ background: var(--muted-foreground);
+ }
}
@layer base {
- * {
- @apply border-border outline-ring/50;
- }
- body {
- @apply bg-background text-foreground;
- }
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index c2608bd9..f8e6040f 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -29,11 +29,20 @@ export default function RootLayout({
<head>
<BotIdClient protect={protectedRoutes} />
{process.env.NODE_ENV === "development" && (
- <Script
- src="//unpkg.com/react-scan/dist/auto.global.js"
- crossOrigin="anonymous"
- strategy="beforeInteractive"
- />
+ <>
+ <Script
+ src="//unpkg.com/react-scan/dist/auto.global.js"
+ crossOrigin="anonymous"
+ strategy="beforeInteractive"
+ />
+
+ {/* code to figma */}
+ {/* <script
+ dangerouslySetInnerHTML={{
+ __html: `(function(){var s=document.createElement('script');s.src='https://mcp.figma.com/mcp/html-to-design/capture.js';document.head.appendChild(s);})();`,
+ }}
+ /> */}
+ </>
)}
</head>
<body className={`${siteFont.className} font-sans antialiased`}>
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx
index 4b06405a..0672cfe5 100644
--- a/apps/web/src/app/privacy/page.tsx
+++ b/apps/web/src/app/privacy/page.tsx
@@ -37,7 +37,7 @@ export default function PrivacyPage() {
</AccordionTrigger>
<AccordionContent>
<h3 className="mb-3 text-lg font-medium">
- Your content stays private and encrypted.
+ Your content never leaves your device.
</h3>
<ol className="list-decimal space-y-2 pl-6">
<li>
@@ -45,22 +45,19 @@ export default function PrivacyPage() {
your files
</li>
<li>
- AI features require encrypted uploads - your content is
- encrypted before leaving your device
+ AI features like auto captions run locally in your browser
+ too - nothing is uploaded
</li>
<li>
- We only collect your email and basic profile info for your
- account
+ OpenCut does not currently require an account or login
</li>
<li>Project data stays on your device, not our servers</li>
<li>
- We use analytics to improve the app, but no personal video
+ We use anonymized analytics to improve the app, but no personal video
content is tracked
</li>
- <li>
- You can delete your account anytime and all data gets removed
- </li>
- <li>We don't sell your data or share it with advertisers</li>
+ <li>You can clear local data from your browser at any time</li>
+ <li>We don't sell or share your data with anyone (we don't even have it)</li>
</ol>
<p className="mt-4">
Questions? Email us at{" "}
@@ -78,84 +75,30 @@ export default function PrivacyPage() {
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">How We Handle Your Content</h2>
<p>
- <strong>Basic video editing happens locally on your device.</strong>{" "}
- For standard editing features, we never upload, store, or have access
- to your video files. Your content remains completely private and under
- your control.
- </p>
- <p>
- <strong>AI features require secure processing:</strong> When you
- choose to use AI features like auto captions, your audio/video content
- is encrypted on your device before being uploaded to our servers for
- processing. We use zero-knowledge encryption, meaning we cannot
- decrypt or view your content.
- </p>
- <p>
- After AI processing is complete, the encrypted content is immediately
- deleted from our servers. Only the results (like generated captions)
- are returned to your device.
+ <strong>
+ All editing and processing happens locally on your device.
+ </strong>{" "}
+ We never upload, store, or have access to your video or audio files.
+ Your content remains completely private and under your control.
+ AI-powered features like auto captions also run in your browser using
+ on-device models - no content ever leaves your device.
</p>
</section>
<section className="flex flex-col gap-3">
- <h2 className="text-2xl font-semibold">Account Information</h2>
- <p>When you create an account, we only collect:</p>
- <ul className="list-disc space-y-2 pl-6">
- <li>Email address (for account access)</li>
- <li>
- Profile information from Google OAuth (if you choose to sign in with
- Google)
- </li>
- </ul>
- <p>
- <strong>We do NOT store your projects on our servers.</strong> All
- project data, including names, thumbnails, and creation dates, is
- stored locally in your browser using IndexedDB.
- </p>
+ <h2 className="text-2xl font-semibold">Accounts & Authentication</h2>
<p>
- We use{" "}
- <a
- href="https://www.better-auth.com"
- target="_blank"
- rel="noopener"
- className="text-primary hover:underline"
- >
- Better Auth
- </a>{" "}
- for secure authentication and follow industry-standard security
- practices.
+ OpenCut does not currently offer user accounts, login, or Google
+ sign-in.
</p>
- </section>
-
- <section className="flex flex-col gap-3">
- <h2 className="text-2xl font-semibold">AI Features & Encryption</h2>
<p>
- When you use AI-powered features (like auto captions, content
- analysis, or enhancement tools), your content needs to be processed on
- our servers. Here's how we protect your privacy:
+ Because there is no account system today, we do not collect account
+ emails, profile information, or OAuth identity data.
</p>
- <ul className="list-disc space-y-2 pl-6">
- <li>
- <strong>Client-side encryption:</strong> Your content is encrypted
- on your device before upload
- </li>
- <li>
- <strong>Zero-knowledge processing:</strong> We cannot decrypt or
- view your original content
- </li>
- <li>
- <strong>Temporary processing:</strong> Encrypted content is deleted
- immediately after processing
- </li>
- <li>
- <strong>Opt-in only:</strong> AI features are optional - basic
- editing remains fully local
- </li>
- </ul>
<p>
- Different AI features may process different types of content (audio
- for captions, video for analysis, etc.), but all follow the same
- zero-knowledge encryption approach.
+ Your projects are never stored on our servers. All project data,
+ including names, thumbnails, and creation dates, is stored locally
+ in your browser using IndexedDB.
</p>
</section>
@@ -171,13 +114,12 @@ export default function PrivacyPage() {
>
Databuddy
</a>{" "}
- for completely anonymized and non-invasive analytics to understand how
- people use OpenCut.
+ for basic, anonymized visitor counts. We do not track clicks,
+ interactions, or how you use the editor.
</p>
<p>
- This helps us improve the editor, but we never collect personal
- information, track individual users, or store any data that could
- identify you.
+ No personal information is collected, no individual users are tracked,
+ and no data that could identify you is stored.
</p>
</section>
@@ -187,7 +129,7 @@ export default function PrivacyPage() {
<ul className="list-disc space-y-2 pl-6">
<li>Save your projects locally on your device</li>
<li>Remember your editor preferences and settings</li>
- <li>Keep you logged in across browser sessions</li>
+ <li>Store app state needed for the editor to work between sessions</li>
</ul>
<p>
All data stays on your device and can be cleared at any time through
@@ -200,10 +142,6 @@ export default function PrivacyPage() {
<p>OpenCut integrates with these services:</p>
<ul className="list-disc space-y-2 pl-6">
<li>
- <strong>Google OAuth:</strong> For optional Google sign-in (governed
- by Google's privacy policy)
- </li>
- <li>
<strong>Vercel:</strong> For hosting and content delivery
</li>
<li>
@@ -216,8 +154,7 @@ export default function PrivacyPage() {
<h2 className="text-2xl font-semibold">Your Rights</h2>
<p>You have complete control over your data:</p>
<ul className="list-disc space-y-2 pl-6">
- <li>Delete your account and all associated data at any time</li>
- <li>Export your project data</li>
+ <li>No account is required to use OpenCut today</li>
<li>Clear local storage to remove all saved projects</li>
<li>Contact us with any privacy concerns</li>
</ul>
@@ -280,7 +217,7 @@ export default function PrivacyPage() {
<Separator />
<p className="text-muted-foreground text-sm">
- Last updated: July 14, 2025
+ Last updated: March 15, 2026
</p>
</BasePage>
);
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index c25d75b9..9618de27 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -7,6 +7,7 @@ import type { KeyboardEvent, MouseEvent } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
+import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
@@ -18,7 +19,7 @@ import type {
TProjectMetadata,
TProjectSortKey,
TProjectSortOption,
-} from "@/types/project";
+} from "@/lib/project/types";
import { formatTimeCode } from "@/lib/time";
import { formatDate } from "@/utils/date";
import { HugeiconsIcon } from "@hugeicons/react";
@@ -86,6 +87,13 @@ const VIEW_MODE_OPTIONS = [
export default function ProjectsPage() {
const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
const editor = useEditor();
+ const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
+
+ const isLoading = useEditor((e) => e.project.getIsLoading());
+ const isInitialized = useEditor((e) => e.project.getIsInitialized());
+ const projectsToDisplay = useEditor((e) =>
+ e.project.getFilteredAndSortedProjects({ searchQuery, sortOption }),
+ );
useEffect(() => {
if (!editor.project.getIsInitialized()) {
@@ -93,18 +101,10 @@ export default function ProjectsPage() {
}
}, [editor.project]);
- const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
- const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
- searchQuery,
- sortOption,
- });
-
- const isLoading = editor.project.getIsLoading();
- const isInitialized = editor.project.getIsInitialized();
-
return (
<div className="bg-background min-h-screen">
<MigrationDialog />
+ <StoragePersistenceDialog />
<ProjectsHeader />
<ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
<main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts
index 767f08df..2c79aa0a 100644
--- a/apps/web/src/app/projects/store.ts
+++ b/apps/web/src/app/projects/store.ts
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-import type { TProjectSortKey } from "@/types/project";
+import type { TProjectSortKey } from "@/lib/project/types";
export type ProjectsViewMode = "grid" | "list";
diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx
index d5d43ba7..697a89ef 100644
--- a/apps/web/src/app/terms/page.tsx
+++ b/apps/web/src/app/terms/page.tsx
@@ -41,18 +41,17 @@ export default function TermsPage() {
</h3>
<ol className="list-decimal space-y-2 pl-6">
<li>
- Your content stays private - basic editing is local, AI features
- use encrypted uploads
+ Everything runs locally in your browser - nothing is ever
+ uploaded to our servers
</li>
<li>
- We never claim ownership of your content, even when processing
- AI features
+ We never claim ownership of your content
</li>
<li>
Free for personal and commercial use with no watermarks or
restrictions
</li>
- <li>Don't use OpenCut for illegal activities or harassment</li>
+ <li>You're responsible for how you use it - don't break the law</li>
<li>
Service provided "as is" - we can't guarantee perfect uptime
</li>
@@ -61,8 +60,7 @@ export default function TermsPage() {
needed
</li>
<li>
- You can delete your account anytime and keep using your exported
- videos
+ No account required - your exported videos are always yours
</li>
</ol>
<p className="mt-4">
@@ -81,21 +79,17 @@ export default function TermsPage() {
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Your Content, Your Rights</h2>
<p>
- <strong>You own everything you create.</strong> OpenCut processes
- basic editing locally on your device. For AI features, content is
- encrypted before upload and we cannot access your original files. We
- make no claims to ownership, licensing, or rights over your videos,
- projects, or any content you create using OpenCut.
+ <strong>You own everything you create.</strong> All editing and
+ processing happens locally on your device. We never see, store, or
+ have access to your files. We make no claims to ownership, licensing,
+ or rights over your videos, projects, or any content you create using
+ OpenCut.
</p>
<ul className="list-disc space-y-2 pl-6">
<li>
- Your content remains private and under your control at all times
+ Your content never leaves your device
</li>
<li>You retain all intellectual property rights to your content</li>
- <li>
- Even when using AI features, we cannot access your unencrypted
- content
- </li>
<li>You can export and use your content however you choose</li>
<li>No watermarks, no licensing restrictions from OpenCut</li>
</ul>
@@ -115,53 +109,26 @@ export default function TermsPage() {
</li>
</ul>
<p>
- <strong>What we ask:</strong> Don't use OpenCut for illegal
- activities, harassment, or creating harmful content. Be respectful of
- others and follow applicable laws.
+ You're responsible for how you use OpenCut and the content you create.
+ Don't use it for anything illegal in your jurisdiction.
</p>
</section>
<section className="flex flex-col gap-3">
- <h2 className="text-2xl font-semibold">
- AI Features and Data Processing
- </h2>
+ <h2 className="text-2xl font-semibold">AI Features</h2>
<p>
- OpenCut offers optional AI-powered features that require server
- processing:
- </p>
- <ul className="list-disc space-y-2 pl-6">
- <li>
- AI features (auto captions, content analysis, etc.) are completely
- optional
- </li>
- <li>Your content is encrypted on your device before any upload</li>
- <li>
- We use zero-knowledge encryption - we cannot decrypt your content
- </li>
- <li>Encrypted content is deleted immediately after processing</li>
- <li>
- You maintain full ownership and control of your content throughout
- </li>
- </ul>
- <p>
- By using AI features, you consent to the temporary, encrypted
- processing of your content as described in our Privacy Policy. You can
- always choose to use only local editing features.
+ AI features like auto captions run entirely in your browser using
+ on-device models. No content is uploaded to any server. These features
+ are optional - you can use OpenCut without them.
</p>
</section>
<section className="flex flex-col gap-3">
- <h2 className="text-2xl font-semibold">Account and Service</h2>
- <p>To use certain features, you may create an account:</p>
- <ul className="list-disc space-y-2 pl-6">
- <li>Provide accurate information when signing up</li>
- <li>Keep your account secure and don't share credentials</li>
- <li>You're responsible for activity under your account</li>
- <li>You can delete your account at any time</li>
- </ul>
+ <h2 className="text-2xl font-semibold">Service</h2>
<p>
- OpenCut is provided "as is" without warranties. While we strive for
- reliability, we can't guarantee uninterrupted service.
+ OpenCut does not currently require an account. The service is provided
+ "as is" without warranties. While we strive for reliability, we can't
+ guarantee uninterrupted service.
</p>
</section>
@@ -189,24 +156,6 @@ export default function TermsPage() {
</section>
<section className="flex flex-col gap-3">
- <h2 className="text-2xl font-semibold">Third-Party Content</h2>
- <p>
- When using OpenCut, make sure you have the right to use any content
- you import:
- </p>
- <ul className="list-disc space-y-2 pl-6">
- <li>Only upload content you own or have permission to use</li>
- <li>
- Respect copyright, trademarks, and other intellectual property
- </li>
- <li>
- Don't use copyrighted music, images, or videos without permission
- </li>
- <li>You're responsible for any claims related to your content</li>
- </ul>
- </section>
-
- <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Limitations and Liability</h2>
<p>
OpenCut is provided free of charge. To the extent permitted by law:
@@ -239,13 +188,10 @@ export default function TermsPage() {
</section>
<section className="flex flex-col gap-3">
- <h2 className="text-2xl font-semibold">Termination</h2>
+ <h2 className="text-2xl font-semibold">Stopping Use</h2>
<p>You can stop using OpenCut at any time:</p>
<ul className="list-disc space-y-2 pl-6">
- <li>Delete your account through your profile settings</li>
<li>Clear your browser data to remove local projects</li>
- <li>Your content remains yours even if you stop using OpenCut</li>
- <li>We may suspend accounts for violations of these terms</li>
</ul>
</section>
@@ -288,7 +234,7 @@ export default function TermsPage() {
</section>
<Separator />
<p className="text-muted-foreground text-sm">
- Last updated: July 14, 2025
+ Last updated: March 15, 2026
</p>
</BasePage>
);
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx
index 61f23372..40390f07 100644
--- a/apps/web/src/components/editable-timecode.tsx
+++ b/apps/web/src/components/editable-timecode.tsx
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode } from "@/lib/time";
-import type { TTimeCode } from "@/types/time";
+import type { TTimeCode } from "@/lib/time";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
@@ -66,16 +66,13 @@ export function EditableTimecode({
enterPressedRef.current = false;
};
- const handleKeyDown = ({
- key,
- preventDefault,
- }: React.KeyboardEvent<HTMLInputElement>) => {
- if (key === "Enter") {
- preventDefault();
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
enterPressedRef.current = true;
applyEdit();
- } else if (key === "Escape") {
- preventDefault();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
cancelEditing();
}
};
@@ -93,14 +90,13 @@ export function EditableTimecode({
}
};
- const handleDisplayKeyDown = ({
- key,
- preventDefault,
- }: React.KeyboardEvent<HTMLButtonElement>) => {
+ const handleDisplayKeyDown = (
+ event: React.KeyboardEvent<HTMLButtonElement>,
+ ) => {
if (disabled) return;
- if (key === "Enter" || key === " ") {
- preventDefault();
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
startEditing();
}
};
diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
index 7b736b23..42f99c79 100644
--- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
+++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
@@ -6,7 +6,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import type { TProjectMetadata } from "@/types/project";
+import type { TProjectMetadata } from "@/lib/project/types";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time";
import { Button } from "@/components/ui/button";
diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
index 565fe705..9a353a1c 100644
--- a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
+++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
@@ -110,7 +110,7 @@ export function ShortcutsDialog({
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
- <DialogBody className="scrollbar-thin flex-grow overflow-y-auto">
+ <DialogBody className="scrollbar-thin grow overflow-y-auto">
<div className="flex flex-col gap-6">
{categories.map((category) => (
<div key={category} className="flex flex-col gap-1">
diff --git a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx
new file mode 100644
index 00000000..d391928c
--- /dev/null
+++ b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { useStoragePersistence } from "@/hooks/use-storage-persistence";
+
+export function StoragePersistenceDialog() {
+ const { showDialog, onConfirm, onDismiss } = useStoragePersistence();
+
+ return (
+ <Dialog open={showDialog} onOpenChange={(open) => !open && onDismiss()}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Don't lose your projects</DialogTitle>
+ </DialogHeader>
+ <DialogBody>
+ <p className="text-base text-muted-foreground">
+ Your browser can automatically delete your projects when storage
+ runs low.
+ </p>
+ <p className="text-base text-muted-foreground">
+ Allow OpenCut to protect them?
+ </p>
+ </DialogBody>
+ <DialogFooter>
+ <Button variant="outline" onClick={onDismiss}>
+ Not now
+ </Button>
+ <Button onClick={onConfirm}>Allow</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index 2dea73e2..8dd730b5 100644
--- a/apps/web/src/components/editor/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -47,7 +47,7 @@ function ProjectDropdown() {
const [isExiting, setIsExiting] = useState(false);
const router = useRouter();
const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const activeProject = useEditor((e) => e.project.getActive());
const handleExit = async () => {
if (isExiting) return;
@@ -136,7 +136,7 @@ function ProjectDropdown() {
<DropdownMenuSeparator />
- <DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}>
+ <DropdownMenuItem asChild icon={<FaDiscord className="size-4!" />}>
<Link
href={SOCIAL_LINKS.discord}
target="_blank"
@@ -169,7 +169,7 @@ function ProjectDropdown() {
function EditableProjectName() {
const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const activeProject = useEditor((e) => e.project.getActive());
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const originalNameRef = useRef("");
@@ -219,6 +219,7 @@ function EditableProjectName() {
event.preventDefault();
if (inputRef.current) {
inputRef.current.value = originalNameRef.current;
+ inputRef.current.setSelectionRange(0, 0);
}
setIsEditing(false);
inputRef.current?.blur();
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index ddf8308d..ec39e7e6 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -14,20 +14,24 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/utils/ui";
-import { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export";
+import {
+ getExportMimeType,
+ getExportFileExtension,
+ downloadBuffer,
+} from "@/lib/export";
import { Check, Copy, Download, RotateCcw } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
type ExportFormat,
type ExportQuality,
-} from "@/types/export";
+} from "@/lib/export";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
-} from "@/components/editor/panels/properties/section";
+} from "@/components/section";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
@@ -42,8 +46,8 @@ function isExportQuality(value: string): value is ExportQuality {
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const editor = useEditor();
-
- const hasProject = !!editor.project.getActiveOrNull();
+ const activeProject = useEditor((e) => e.project.getActiveOrNull());
+ const hasProject = !!activeProject;
const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
if (!open) {
@@ -54,7 +58,10 @@ export function ExportButton() {
};
return (
- <Popover open={isExportPopoverOpen} onOpenChange={(open) => handlePopoverOpenChange({ open })}>
+ <Popover
+ open={isExportPopoverOpen}
+ onOpenChange={(open) => handlePopoverOpenChange({ open })}
+ >
<PopoverTrigger asChild>
<button
type="button"
@@ -91,9 +98,9 @@ function ExportPopover({
onOpenChange: (open: boolean) => void;
}) {
const editor = useEditor();
- const activeProject = editor.project.getActive();
- const { isExporting, progress, result: exportResult } =
- editor.project.getExportState();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const exportState = useEditor((e) => e.project.getExportState());
+ const { isExporting, progress, result: exportResult } = exportState;
const [format, setFormat] = useState<ExportFormat>(
DEFAULT_EXPORT_OPTIONS.format,
);
@@ -109,10 +116,10 @@ function ExportPopover({
const result = await editor.project.export({
options: {
- format,
- quality,
- fps: activeProject.settings.fps,
- includeAudio: shouldIncludeAudio,
+ format,
+ quality,
+ fps: activeProject.settings.fps,
+ includeAudio: shouldIncludeAudio,
},
});
@@ -156,7 +163,11 @@ function ExportPopover({
{!isExporting && (
<>
<div className="flex flex-col">
- <Section collapsible defaultOpen={false} showTopBorder={false}>
+ <Section
+ collapsible
+ defaultOpen={false}
+ showTopBorder={false}
+ >
<SectionHeader>
<SectionTitle>Format</SectionTitle>
</SectionHeader>
@@ -228,10 +239,10 @@ function ExportPopover({
<div className="flex items-center space-x-2">
<Checkbox
id="include-audio"
- checked={shouldIncludeAudio}
- onCheckedChange={(checked) =>
- setShouldIncludeAudio(!!checked)
- }
+ checked={shouldIncludeAudio}
+ onCheckedChange={(checked) =>
+ setShouldIncludeAudio(!!checked)
+ }
/>
<Label htmlFor="include-audio">
Include audio in export
@@ -252,15 +263,15 @@ function ExportPopover({
{isExporting && (
<div className="space-y-4 p-3">
- <div className="flex flex-col gap-2">
- <div className="flex items-center justify-between text-center">
- <p className="text-muted-foreground text-sm">
- {Math.round(progress * 100)}%
- </p>
- <p className="text-muted-foreground text-sm">100%</p>
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center justify-between text-center">
+ <p className="text-muted-foreground text-sm">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-muted-foreground text-sm">100%</p>
+ </div>
+ <Progress value={progress * 100} className="w-full" />
</div>
- <Progress value={progress * 100} className="w-full" />
- </div>
<Button
variant="outline"
diff --git a/apps/web/src/components/editor/mobile-gate.tsx b/apps/web/src/components/editor/mobile-gate.tsx
index 8fecf3cb..6483e438 100644
--- a/apps/web/src/components/editor/mobile-gate.tsx
+++ b/apps/web/src/components/editor/mobile-gate.tsx
@@ -36,7 +36,7 @@ export function MobileGate({ children }: MobileGateProps) {
};
return (
- <div className="dark bg-background relative flex h-screen w-screen flex-col overflow-hidden">
+ <div className="bg-background relative flex h-screen w-screen flex-col overflow-hidden">
<Button
variant="text"
className="absolute top-6 left-6 flex items-center gap-1 text-muted-foreground"
@@ -58,9 +58,7 @@ export function MobileGate({ children }: MobileGateProps) {
</p>
</div>
<div className="flex items-center gap-3">
- <Button onClick={handleContinue}>
- Take a look anyway
- </Button>
+ <Button onClick={handleContinue}>Take a look anyway</Button>
<Button variant="ghost" asChild>
<Link href="/roadmap" className="flex items-center gap-1">
Roadmap
diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
index c6b0861c..9d9ae903 100644
--- a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
+++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
@@ -29,7 +29,7 @@ export function MediaDragOverlay({
return (
<button
- className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center"
+ className="bg-foreground/5 hover:bg-foreground/10 flex flex-col items-center justify-center gap-4 rounded-lg p-8 text-center"
type="button"
disabled={isProcessing || !onClick}
onClick={(event) => handleClick({ event })}
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
index fae74e84..8c9acce8 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import { clearDragData, setDragData } from "@/lib/drag-data";
-import type { TimelineDragData } from "@/types/drag";
+import type { TimelineDragData } from "@/lib/timeline/drag";
import { cn } from "@/utils/ui";
export interface DraggableItemProps {
@@ -29,7 +29,6 @@ export interface DraggableItemProps {
isRounded?: boolean;
variant?: "card" | "compact";
isDraggable?: boolean;
- isHighlighted?: boolean;
}
export function DraggableItem({
@@ -46,13 +45,11 @@ export function DraggableItem({
isRounded = true,
variant = "card",
isDraggable = true,
- isHighlighted = false,
}: DraggableItemProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef<HTMLDivElement>(null);
const editor = useEditor();
- const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`;
const handleAddToTimeline = () => {
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
@@ -102,9 +99,8 @@ export function DraggableItem({
>
<div
className={cn(
- "relative flex h-auto w-full cursor-default flex-col gap-1 p-1",
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
className,
- isHighlighted && highlightClassName,
)}
>
<AspectRatio
@@ -144,15 +140,12 @@ export function DraggableItem({
) : (
<div
ref={dragRef}
- className={cn(
- "group relative w-full",
- isHighlighted && highlightClassName,
- )}
+ className={cn("group relative w-full", containerClassName)}
>
<button
type="button"
className={cn(
- "flex h-8 w-full cursor-default items-center gap-3 px-1",
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
className,
)}
@@ -160,7 +153,7 @@ export function DraggableItem({
onDragStart={isDraggable ? handleDragStart : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
>
- <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
{preview}
</div>
<span className="w-full flex-1 truncate text-sm text-left">
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index 61777a7c..a4e072f1 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -49,7 +49,7 @@ export function TabBar() {
<div className="relative flex">
<div
ref={scrollRef}
- className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto"
+ className="scrollbar-hidden relative flex size-full p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto"
>
{TAB_KEYS.map((tabKey) => {
const tab = tabs[tabKey];
@@ -57,12 +57,13 @@ export function TabBar() {
<Tooltip key={tabKey} delayDuration={10}>
<TooltipTrigger asChild>
<Button
- variant={activeTab === tabKey ? "secondary" : "text"}
+ variant={activeTab === tabKey ? "secondary" : "ghost"}
+ size="icon"
aria-label={tab.label}
className={cn(
- "flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
- activeTab !== tabKey &&
- "border border-transparent text-muted-foreground",
+ "shrink-0",
+ "h-8 w-8",
+ activeTab !== tabKey && "text-muted-foreground",
)}
onClick={() => setActiveTab(tabKey)}
>
@@ -102,8 +103,8 @@ function FadeOverlay({
className={cn(
"pointer-events-none absolute right-0 left-0 h-6",
direction === "top" && show
- ? "from-background top-0 bg-gradient-to-b to-transparent"
- : "from-background bottom-0 bg-gradient-to-t to-transparent",
+ ? "from-background top-0 bg-linear-to-b to-transparent"
+ : "from-background bottom-0 bg-linear-to-t to-transparent",
)}
/>
);
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 3b35c022..e17e4d4a 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -2,11 +2,11 @@
import Image from "next/image";
import { useMemo, useState } from "react";
-import { toast } from "sonner";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
import {
ContextMenu,
ContextMenuContent,
@@ -28,8 +28,15 @@ import {
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
-import { useRevealItem } from "@/hooks/use-reveal-item";
+import { invokeAction } from "@/lib/actions";
import { processMediaAssets } from "@/lib/media/processing";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import {
+ SelectableItem,
+ SelectableSurface,
+ useSelection,
+ useSelectionScope,
+} from "@/lib/selection";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import {
type MediaSortKey,
@@ -37,7 +44,8 @@ import {
type MediaViewMode,
useAssetsPanelStore,
} from "@/stores/assets-panel-store";
-import type { MediaAsset } from "@/types/assets";
+import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { cn } from "@/utils/ui";
import {
CloudUploadIcon,
@@ -52,8 +60,8 @@ import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
export function MediaView() {
const editor = useEditor();
- const mediaFiles = editor.media.getAssets();
- const activeProject = editor.project.getActive();
+ const mediaFiles = useEditor((e) => e.media.getAssets());
+ const activeProject = useEditor((e) => e.project.getActive());
const {
mediaViewMode,
@@ -64,15 +72,11 @@ export function MediaView() {
mediaSortOrder,
setMediaSort,
} = useAssetsPanelStore();
- const { highlightedId, registerElement } = useRevealItem(
- highlightMediaId,
- clearHighlight,
- );
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
- const processFiles = async ({ files }: { files: FileList }) => {
+ const processFiles = async ({ files }: { files: File[] }) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
@@ -82,20 +86,28 @@ export function MediaView() {
setIsProcessing(true);
setProgress(0);
try {
- const processedAssets = await processMediaAssets({
- files,
- onProgress: (progress: { progress: number }) =>
- setProgress(progress.progress),
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({
+ files,
+ onProgress: (progress: { progress: number }) =>
+ setProgress(progress.progress),
+ });
+ for (const asset of processedAssets) {
+ await editor.media.addMediaAsset({
+ projectId: activeProject.metadata.id,
+ asset,
+ });
+ }
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
});
- for (const asset of processedAssets) {
- await editor.media.addMediaAsset({
- projectId: activeProject.metadata.id,
- asset,
- });
- }
} catch (error) {
console.error("Error processing files:", error);
- toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
@@ -109,23 +121,18 @@ export function MediaView() {
onFilesSelected: (files) => processFiles({ files }),
});
- const handleRemove = async ({
+ const handleRemove = ({
event,
- id,
+ ids,
}: {
event: React.MouseEvent;
- id: string;
+ ids: string[];
}) => {
event.stopPropagation();
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- await editor.media.removeMediaAsset({
+ invokeAction("remove-media-assets", {
projectId: activeProject.metadata.id,
- id,
+ assetIds: ids,
});
};
@@ -172,6 +179,9 @@ export function MediaView() {
return filtered;
}, [mediaFiles, mediaSortBy, mediaSortOrder]);
+ const orderedMediaIds = useMemo(() => {
+ return filteredMediaItems.map((item) => item.id);
+ }, [filteredMediaItems]);
return (
<>
@@ -191,6 +201,7 @@ export function MediaView() {
/>
}
className={cn(isDragOver && "bg-accent/30")}
+ contentClassName="h-full"
{...dragProps}
>
{isDragOver || filteredMediaItems.length === 0 ? (
@@ -201,29 +212,38 @@ export function MediaView() {
onClick={openFilePicker}
/>
) : (
- <MediaItemList
- items={filteredMediaItems}
- mode={mediaViewMode}
- onRemove={handleRemove}
- highlightedId={highlightedId}
- registerElement={registerElement}
- />
+ <SelectableSurface
+ ariaLabel="Assets"
+ orderedIds={orderedMediaIds}
+ revealId={highlightMediaId}
+ onRevealComplete={clearHighlight}
+ >
+ <MediaScopeRegistrar />
+ <MediaItemList
+ items={filteredMediaItems}
+ mode={mediaViewMode}
+ onRemove={handleRemove}
+ />
+ </SelectableSurface>
)}
</PanelView>
</>
);
}
+function MediaScopeRegistrar() {
+ useSelectionScope();
+ return null;
+}
+
function MediaAssetDraggable({
item,
preview,
- isHighlighted,
variant,
isRounded,
}: {
item: MediaAsset;
preview: React.ReactNode;
- isHighlighted: boolean;
variant: "card" | "compact";
isRounded?: boolean;
}) {
@@ -261,7 +281,7 @@ function MediaAssetDraggable({
mediaType: item.type,
name: item.name,
...(item.type !== "audio" && {
- targetElementTypes: ["video", "image"] as const,
+ targetElementTypes: [...MASKABLE_ELEMENT_TYPES],
}),
}}
shouldShowPlusOnDrag={false}
@@ -270,7 +290,6 @@ function MediaAssetDraggable({
}
variant={variant}
isRounded={isRounded}
- isHighlighted={isHighlighted}
/>
);
}
@@ -282,18 +301,31 @@ function MediaItemWithContextMenu({
}: {
item: MediaAsset;
children: React.ReactNode;
- onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+ onRemove: ({
+ event,
+ ids,
+ }: {
+ event: React.MouseEvent;
+ ids: string[];
+ }) => void;
}) {
+ const { isSelected, selectedIds } = useSelection();
+ const idsToDelete = isSelected(item.id) ? selectedIds : [item.id];
+ const deleteLabel =
+ idsToDelete.length > 1 ? `Delete ${idsToDelete.length} items` : "Delete";
+
return (
<ContextMenu>
- <ContextMenuTrigger>{children}</ContextMenuTrigger>
+ <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
- onClick={(event) => onRemove({ event, id: item.id })}
+ onClick={(event: React.MouseEvent<HTMLDivElement>) =>
+ onRemove({ event, ids: idsToDelete })
+ }
>
- Delete
+ {deleteLabel}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -304,27 +336,29 @@ function MediaItemList({
items,
mode,
onRemove,
- highlightedId,
- registerElement,
}: {
items: MediaAsset[];
mode: MediaViewMode;
- onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
- highlightedId: string | null;
- registerElement: (id: string, element: HTMLElement | null) => void;
+ onRemove: ({
+ event,
+ ids,
+ }: {
+ event: React.MouseEvent;
+ ids: string[];
+ }) => void;
}) {
const isGrid = mode === "grid";
return (
<div
- className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1")}
+ className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1.5")}
style={
isGrid ? { gridTemplateColumns: "repeat(auto-fill, 7rem)" } : undefined
}
>
{items.map((item) => (
- <div key={item.id} ref={(element) => registerElement(item.id, element)}>
- <MediaItemWithContextMenu item={item} onRemove={onRemove}>
+ <MediaItemWithContextMenu item={item} onRemove={onRemove} key={item.id}>
+ <SelectableItem className={cn(!isGrid && "w-full")} id={item.id}>
<MediaAssetDraggable
item={item}
preview={
@@ -335,16 +369,15 @@ function MediaItemList({
}
variant={isGrid ? "card" : "compact"}
isRounded={isGrid ? false : undefined}
- isHighlighted={highlightedId === item.id}
/>
- </MediaItemWithContextMenu>
- </div>
+ </SelectableItem>
+ </MediaItemWithContextMenu>
))}
</div>
);
}
-export function formatDuration({ duration }: { duration: number }) {
+function formatDuration({ duration }: { duration: number }) {
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
@@ -406,7 +439,7 @@ function MediaPreview({
if (item.type === "image") {
return (
- <div className="relative flex size-full items-center justify-center">
+ <div className="relative flex size-full items-center justify-center bg-muted">
<Image
src={item.url ?? ""}
alt={item.name}
diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx
index c613bc2a..0ece9b19 100644
--- a/apps/web/src/components/editor/panels/assets/views/base-view.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx
@@ -5,6 +5,7 @@ interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
actions?: React.ReactNode;
children: React.ReactNode;
contentClassName?: string;
+ scrollClassName?: string;
hideHeader?: boolean;
ref?: React.Ref<HTMLDivElement>;
onScroll?: React.UIEventHandler<HTMLDivElement>;
@@ -17,6 +18,7 @@ export function PanelView({
children,
className,
contentClassName,
+ scrollClassName,
hideHeader = false,
ref,
onScroll,
@@ -30,15 +32,18 @@ export function PanelView({
{...rest}
>
{!hideHeader && (
- <div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b">
- <span className="text-muted-foreground text-sm">{title}</span>
+ <div className="bg-background h-11 shrink-0 pl-3 pr-2 flex items-center justify-between border-b">
+ {title && (
+ <span className="text-muted-foreground text-sm">{title}</span>
+ )}
{actions}
</div>
)}
<div
className={cn(
- "scrollbar-thin size-full overflow-y-auto",
+ "scrollbar-hidden size-full overflow-y-auto",
hideHeader ? "pt-4" : "pt-2",
+ scrollClassName,
)}
ref={scrollRef}
onScroll={onScroll}
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx
index a73fd693..023e1578 100644
--- a/apps/web/src/components/editor/panels/assets/views/captions.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx
@@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import {
Select,
SelectContent,
@@ -10,7 +10,6 @@ import {
import { useState, useRef } from "react";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import {
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
TRANSCRIPTION_LANGUAGES,
@@ -18,12 +17,23 @@ import {
import type {
TranscriptionLanguage,
TranscriptionProgress,
-} from "@/types/transcription";
+} from "@/lib/transcription/types";
import { transcriptionService } from "@/services/transcription/service";
import { decodeAudioToFloat32 } from "@/lib/media/audio";
import { buildCaptionChunks } from "@/lib/transcription/caption";
import { Spinner } from "@/components/ui/spinner";
-import { Label } from "@/components/ui/label";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+} from "@/components/section";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ AddTrackCommand,
+ BatchCommand,
+ InsertElementCommand,
+} from "@/lib/commands";
export function Captions() {
const [selectedLanguage, setSelectedLanguage] =
@@ -69,26 +79,29 @@ export function Captions() {
setProcessingStep("Generating captions...");
const captionChunks = buildCaptionChunks({ segments: result.segments });
- const captionTrackId = editor.timeline.addTrack({
- type: "text",
- index: 0,
- });
+ const addTrackCommand = new AddTrackCommand("text", 0);
+ const insertCommands = captionChunks.map(
+ (caption, i) =>
+ new InsertElementCommand({
+ placement: {
+ mode: "explicit",
+ trackId: addTrackCommand.getTrackId(),
+ },
+ element: {
+ ...DEFAULTS.text.element,
+ name: `Caption ${i + 1}`,
+ content: caption.text,
+ duration: caption.duration,
+ startTime: caption.startTime,
+ fontSize: 65,
+ fontWeight: "bold",
+ },
+ }),
+ );
- for (let i = 0; i < captionChunks.length; i++) {
- const caption = captionChunks[i];
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId: captionTrackId },
- element: {
- ...DEFAULT_TEXT_ELEMENT,
- name: `Caption ${i + 1}`,
- content: caption.text,
- duration: caption.duration,
- startTime: caption.startTime,
- fontSize: 65,
- fontWeight: "bold",
- },
- });
- }
+ editor.command.execute({
+ command: new BatchCommand([addTrackCommand, ...insertCommands]),
+ });
} catch (error) {
console.error("Transcription failed:", error);
setError(
@@ -114,43 +127,53 @@ export function Captions() {
};
return (
- <PanelView title="Captions" ref={containerRef}>
- <div className="flex flex-col gap-3">
- <Label>Language</Label>
- <Select
- value={selectedLanguage}
- onValueChange={(value) => handleLanguageChange({ value })}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select a language" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="auto">Auto detect</SelectItem>
- {TRANSCRIPTION_LANGUAGES.map((language) => (
- <SelectItem key={language.code} value={language.code}>
- {language.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- <div className="flex flex-col gap-4">
- {error && (
- <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
- <p className="text-destructive text-sm">{error}</p>
- </div>
- )}
+ <PanelView
+ title="Captions"
+ contentClassName="px-0 flex flex-col h-full"
+ ref={containerRef}
+ >
+ <Section showTopBorder={false} showBottomBorder={false} className="flex-1">
+ <SectionContent className="flex flex-col gap-4 h-full pt-1">
+ <SectionFields>
+ <SectionField label="Language">
+ <Select
+ value={selectedLanguage}
+ onValueChange={(value) => handleLanguageChange({ value })}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select a language" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="auto">Auto detect</SelectItem>
+ {TRANSCRIPTION_LANGUAGES.map((language) => (
+ <SelectItem key={language.code} value={language.code}>
+ {language.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </SectionFields>
- <Button
- className="w-full"
- onClick={handleGenerateTranscript}
- disabled={isProcessing}
- >
- {isProcessing && <Spinner className="mr-1" />}
- {isProcessing ? processingStep : "Generate transcript"}
- </Button>
- </div>
+ {error && (
+ <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
+ <p className="text-destructive text-sm">{error}</p>
+ </div>
+ )}
+ </SectionContent>
+ </Section>
+ <Section showBottomBorder={false} showTopBorder={false}>
+ <SectionContent>
+ <Button
+ className="w-full"
+ onClick={handleGenerateTranscript}
+ disabled={isProcessing}
+ >
+ {isProcessing && <Spinner className="mr-1" />}
+ {isProcessing ? processingStep : "Generate transcript"}
+ </Button>
+ </SectionContent>
+ </Section>
</PanelView>
);
}
diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx
index c9b8b2dd..74128df6 100644
--- a/apps/web/src/components/editor/panels/assets/views/effects.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/effects.tsx
@@ -1,19 +1,16 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
-import {
- effectPreviewService,
- onPreviewImageReady,
-} from "@/services/renderer/effect-preview";
+import { effectsRegistry, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
import { useEditor } from "@/hooks/use-editor";
import { buildEffectElement } from "@/lib/timeline/element-utils";
-import type { EffectDefinition } from "@/types/effects";
+import type { EffectDefinition } from "@/lib/effects/types";
export function EffectsView() {
- const effects = getAllEffects();
+ const effects = effectsRegistry.getAll();
return (
<PanelView title="Effects">
@@ -50,7 +47,7 @@ function EffectPreviewCanvas({ effectType }: { effectType: string }) {
};
render();
- return onPreviewImageReady({ callback: render });
+ return effectPreviewService.onPreviewImageReady({ callback: render });
}, [effectType]);
return <canvas ref={canvasRef} className="size-full" />;
diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
deleted file mode 100644
index 457c08a9..00000000
--- a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
+++ /dev/null
@@ -1,332 +0,0 @@
-// @ts-nocheck
-
-"use client";
-
-import Image from "next/image";
-import { memo, useCallback, useMemo } from "react";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import {
- BLUR_INTENSITY_PRESETS,
- DEFAULT_BLUR_INTENSITY,
- DEFAULT_COLOR,
- FPS_PRESETS,
-} from "@/constants/project-constants";
-import { patternCraftGradients } from "@/data/colors/pattern-craft";
-import { colors } from "@/data/colors/solid";
-import { syntaxUIGradients } from "@/data/colors/syntax-ui";
-import { useEditor } from "@/hooks/use-editor";
-import { useEditorStore } from "@/stores/editor-store";
-import { dimensionToAspectRatio } from "@/utils/geometry";
-import { cn } from "@/utils/ui";
-import {
- PropertyGroup,
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "@/components/editor/panels/properties/section";
-
-export function SettingsView() {
- return (
- <PanelView title="Project settings" contentClassName="px-0">
- <div className="flex flex-col gap-8">
- <ProjectInfoView />
- <BackgroundView />
- </div>
- </PanelView>
- );
-}
-
-function ProjectInfoView() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const { canvasPresets } = useEditorStore();
-
- const findPresetIndexByAspectRatio = ({
- presets,
- targetAspectRatio,
- }: {
- presets: Array<{ width: number; height: number }>;
- targetAspectRatio: string;
- }) => {
- for (let index = 0; index < presets.length; index++) {
- const preset = presets[index];
- const presetAspectRatio = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- if (presetAspectRatio === targetAspectRatio) {
- return index;
- }
- }
- return -1;
- };
-
- const currentCanvasSize = activeProject.settings.canvasSize;
- const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
- const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
- const presetIndex = findPresetIndexByAspectRatio({
- presets: canvasPresets,
- targetAspectRatio: currentAspectRatio,
- });
- const originalPresetValue = "original";
- const selectedPresetValue =
- presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
-
- const handleAspectRatioChange = ({ value }: { value: string }) => {
- if (value === originalPresetValue) {
- const canvasSize = originalCanvasSize ?? currentCanvasSize;
- editor.project.updateSettings({
- settings: { canvasSize },
- });
- return;
- }
- const index = parseInt(value, 10);
- const preset = canvasPresets[index];
- if (preset) {
- editor.project.updateSettings({ settings: { canvasSize: preset } });
- }
- };
-
- const handleFpsChange = (value: string) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- };
-
- return (
- <div className="flex flex-col gap-4">
- <PropertyItem direction="column">
- <PropertyItemLabel>Name</PropertyItemLabel>
- <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
- </PropertyItem>
-
- <PropertyItem direction="column">
- <PropertyItemLabel>Aspect ratio</PropertyItemLabel>
- <PropertyItemValue>
- <Select
- value={selectedPresetValue}
- onValueChange={(value) => handleAspectRatioChange({ value })}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select an aspect ratio" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value={originalPresetValue}>Original</SelectItem>
- {canvasPresets.map((preset, index) => {
- const label = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- return (
- <SelectItem key={label} value={index.toString()}>
- {label}
- </SelectItem>
- );
- })}
- </SelectContent>
- </Select>
- </PropertyItemValue>
- </PropertyItem>
-
- <PropertyItem direction="column">
- <PropertyItemLabel>Frame rate</PropertyItemLabel>
- <PropertyItemValue>
- <Select
- value={activeProject.settings.fps.toString()}
- onValueChange={handleFpsChange}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select a frame rate" />
- </SelectTrigger>
- <SelectContent>
- {FPS_PRESETS.map((preset) => (
- <SelectItem key={preset.value} value={preset.value}>
- {preset.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </PropertyItemValue>
- </PropertyItem>
- </div>
- );
-}
-
-const BlurPreview = memo(
- ({
- blur,
- isSelected,
- onSelect,
- }: {
- blur: { label: string; value: number };
- isSelected: boolean;
- onSelect: () => void;
- }) => (
- <button
- className={cn(
- "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
- isSelected && "border-primary border-2",
- )}
- onClick={onSelect}
- type="button"
- aria-label={`Select ${blur.label} blur`}
- >
- <Image
- src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
- alt={`Blur preview ${blur.label}`}
- fill
- className="object-cover"
- style={{ filter: `blur(${blur.value}px)` }}
- loading="eager"
- />
- <div className="absolute right-1 bottom-1 left-1 text-center">
- <span className="rounded bg-black/50 px-1 text-xs text-white">
- {blur.label}
- </span>
- </div>
- </button>
- ),
-);
-
-BlurPreview.displayName = "BlurPreview";
-
-const BackgroundPreviews = memo(
- ({
- backgrounds,
- currentBackgroundColor,
- isColorBackground,
- handleColorSelect,
- useBackgroundColor = false,
- }: {
- backgrounds: string[];
- currentBackgroundColor: string;
- isColorBackground: boolean;
- handleColorSelect: ({ bg }: { bg: string }) => void;
- useBackgroundColor?: boolean;
- }) => {
- return useMemo(
- () =>
- backgrounds.map((bg, index) => (
- <button
- key={`${index}-${bg}`}
- className={cn(
- "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
- isColorBackground &&
- bg === currentBackgroundColor &&
- "border-primary border-2",
- )}
- style={
- useBackgroundColor
- ? { backgroundColor: bg }
- : {
- background: bg,
- backgroundSize: "cover",
- backgroundPosition: "center",
- backgroundRepeat: "no-repeat",
- }
- }
- onClick={() => handleColorSelect({ bg })}
- type="button"
- aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
- />
- )),
- [
- backgrounds,
- isColorBackground,
- currentBackgroundColor,
- handleColorSelect,
- useBackgroundColor,
- ],
- );
- },
-);
-
-BackgroundPreviews.displayName = "BackgroundPreviews";
-
-function BackgroundView() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
-
- const handleBlurSelect = useCallback(
- async ({ blurIntensity }: { blurIntensity: number }) => {
- await editor.project.updateSettings({
- settings: { background: { type: "blur", blurIntensity } },
- });
- },
- [editor.project],
- );
-
- const handleColorSelect = useCallback(
- async ({ color }: { color: string }) => {
- await editor.project.updateSettings({
- settings: { background: { type: "color", color } },
- });
- },
- [editor.project],
- );
-
- const currentBlurIntensity =
- activeProject.settings.background.type === "blur"
- ? activeProject.settings.background.blurIntensity
- : DEFAULT_BLUR_INTENSITY;
-
- const currentBackgroundColor =
- activeProject.settings.background.type === "color"
- ? activeProject.settings.background.color
- : DEFAULT_COLOR;
-
- const isBlurBackground = activeProject.settings.background.type === "blur";
- const isColorBackground = activeProject.settings.background.type === "color";
-
- const blurPreviews = useMemo(
- () =>
- blurLevels.map((blur) => (
- <BlurPreview
- key={blur.value}
- blur={blur}
- isSelected={isBlurBackground && currentBlurIntensity === blur.value}
- onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
- />
- )),
- [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
- );
-
- const backgroundSections = [
- { title: "Colors", backgrounds: colors, useBackgroundColor: true },
- { title: "Pattern craft", backgrounds: patternCraftGradients },
- { title: "Syntax UI", backgrounds: syntaxUIGradients },
- ];
-
- return (
- <div className="flex flex-col">
- <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
- <div className="flex flex-wrap gap-2">{blurPreviews}</div>
- </PropertyGroup>
-
- {backgroundSections.map((section) => (
- <PropertyGroup
- key={section.title}
- title={section.title}
- defaultExpanded={false}
- >
- <div className="flex flex-wrap gap-2">
- <BackgroundPreviews
- backgrounds={section.backgrounds}
- currentBackgroundColor={currentBackgroundColor}
- isColorBackground={isColorBackground}
- handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
- useBackgroundColor={section.useBackgroundColor}
- />
- </div>
- </PropertyGroup>
- ))}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx
deleted file mode 100644
index d46e9d1f..00000000
--- a/apps/web/src/components/editor/panels/assets/views/settings.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-"use client";
-
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FPS_PRESETS } from "@/constants/project-constants";
-import { useEditor } from "@/hooks/use-editor";
-import { useEditorStore } from "@/stores/editor-store";
-import { dimensionToAspectRatio } from "@/utils/geometry";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
-} from "@/components/editor/panels/properties/section";
-import { Label } from "@/components/ui/label";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-
-const ORIGINAL_PRESET_VALUE = "original";
-
-export function findPresetIndexByAspectRatio({
- presets,
- targetAspectRatio,
-}: {
- presets: Array<{ width: number; height: number }>;
- targetAspectRatio: string;
-}) {
- for (let index = 0; index < presets.length; index++) {
- const preset = presets[index];
- const presetAspectRatio = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- if (presetAspectRatio === targetAspectRatio) {
- return index;
- }
- }
- return -1;
-}
-
-export function SettingsView() {
- return (
- <PanelView contentClassName="px-0" hideHeader>
- <div className="flex flex-col">
- <Section showTopBorder={false}>
- <SectionContent>
- <ProjectInfoContent />
- </SectionContent>
- </Section>
- <Popover>
- <Section className="cursor-pointer">
- <PopoverTrigger asChild>
- <div>
- <SectionHeader
- trailing={<div className="size-4 rounded-sm bg-red-500" />}
- >
- <SectionTitle>Background</SectionTitle>
- </SectionHeader>
- </div>
- </PopoverTrigger>
- </Section>
- <PopoverContent>
- <div className="size-4 rounded-sm bg-red-500" />
- </PopoverContent>
- </Popover>
- </div>
- </PanelView>
- );
-}
-
-function ProjectInfoContent() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const { canvasPresets } = useEditorStore();
-
- const currentCanvasSize = activeProject.settings.canvasSize;
- const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
- const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
- const presetIndex = findPresetIndexByAspectRatio({
- presets: canvasPresets,
- targetAspectRatio: currentAspectRatio,
- });
- const selectedPresetValue =
- presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE;
-
- const handleAspectRatioChange = ({ value }: { value: string }) => {
- if (value === ORIGINAL_PRESET_VALUE) {
- const canvasSize = originalCanvasSize ?? currentCanvasSize;
- editor.project.updateSettings({
- settings: { canvasSize },
- });
- return;
- }
- const index = parseInt(value, 10);
- const preset = canvasPresets[index];
- if (preset) {
- editor.project.updateSettings({ settings: { canvasSize: preset } });
- }
- };
-
- const handleFpsChange = ({ value }: { value: string }) => {
- const fps = parseFloat(value);
- editor.project.updateSettings({ settings: { fps } });
- };
-
- return (
- <div className="flex flex-col gap-4">
- <div className="flex flex-col gap-2">
- <Label>Name</Label>
- <span className="leading-none text-sm">
- {activeProject.metadata.name}
- </span>
- </div>
- <div className="flex flex-col gap-2">
- <Label>Aspect ratio</Label>
- <Select
- value={selectedPresetValue}
- onValueChange={(value) => handleAspectRatioChange({ value })}
- >
- <SelectTrigger className="w-fit">
- <SelectValue placeholder="Select an aspect ratio" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value={ORIGINAL_PRESET_VALUE}>Original</SelectItem>
- {canvasPresets.map((preset, index) => {
- const label = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- return (
- <SelectItem key={label} value={index.toString()}>
- {label}
- </SelectItem>
- );
- })}
- </SelectContent>
- </Select>
- </div>
- <div className="flex flex-col gap-2">
- <Label>Frame rate</Label>
- <Select
- value={activeProject.settings.fps.toString()}
- onValueChange={(value) => handleFpsChange({ value })}
- >
- <SelectTrigger className="w-fit">
- <SelectValue placeholder="Select a frame rate" />
- </SelectTrigger>
- <SelectContent>
- {FPS_PRESETS.map((preset) => (
- <SelectItem key={preset.value} value={preset.value}>
- {preset.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
new file mode 100644
index 00000000..b3ef1eb1
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx
@@ -0,0 +1,227 @@
+"use client";
+
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import {
+ BLUR_INTENSITY_PRESETS,
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_COLOR,
+} from "@/constants/project-constants";
+import { patternCraftGradients } from "@/data/colors/pattern-craft";
+import { colors } from "@/data/colors/solid";
+import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { useEditor } from "@/hooks/use-editor";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
+import type { TCanvasSize } from "@/lib/project/types";
+import { cn } from "@/utils/ui";
+
+const BlurPreview = memo(
+ ({
+ blur,
+ canvasSize,
+ isSelected,
+ onSelect,
+ }: {
+ blur: { label: string; value: number };
+ canvasSize: TCanvasSize;
+ isSelected: boolean;
+ onSelect: () => void;
+ }) => {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const { width, height } = canvasSize;
+
+ useEffect(() => {
+ const renderPreview = () => {
+ if (!canvasRef.current) return;
+
+ effectPreviewService.renderPreview({
+ effectType: "blur",
+ params: { intensity: blur.value },
+ targetCanvas: canvasRef.current,
+ uniformDimensions: { width, height },
+ });
+ };
+
+ renderPreview();
+ return effectPreviewService.onPreviewImageReady({ callback: renderPreview });
+ }, [blur.value, width, height]);
+
+ return (
+ <button
+ className={cn(
+ "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
+ isSelected && "border-primary border-2",
+ )}
+ onClick={onSelect}
+ type="button"
+ aria-label={`Select ${blur.label} blur`}
+ >
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 h-full w-full object-cover"
+ />
+ <div className="absolute right-1 bottom-1 left-1 text-center">
+ <span className="rounded bg-black/50 px-1 text-xs text-white">
+ {blur.label}
+ </span>
+ </div>
+ </button>
+ );
+ },
+);
+
+BlurPreview.displayName = "BlurPreview";
+
+const BackgroundPreviews = memo(
+ ({
+ backgrounds,
+ currentBackgroundColor,
+ isColorBackground,
+ onSelect,
+ useBackgroundColor = false,
+ }: {
+ backgrounds: string[];
+ currentBackgroundColor: string;
+ isColorBackground: boolean;
+ onSelect: (bg: string) => void;
+ useBackgroundColor?: boolean;
+ }) => {
+ return useMemo(
+ () =>
+ backgrounds.map((bg) => (
+ <button
+ key={bg}
+ className={cn(
+ "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
+ isColorBackground &&
+ bg === currentBackgroundColor &&
+ "border-primary border-2",
+ )}
+ style={
+ useBackgroundColor
+ ? { backgroundColor: bg }
+ : {
+ background: bg,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ backgroundRepeat: "no-repeat",
+ }
+ }
+ onClick={() => onSelect(bg)}
+ type="button"
+ aria-label={`Select background ${bg}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ onSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+const COLOR_SECTIONS = [
+ { title: "Colors", backgrounds: colors, useBackgroundColor: true },
+ { title: "Pattern craft", backgrounds: patternCraftGradients },
+ { title: "Syntax UI", backgrounds: syntaxUIGradients },
+] as const;
+
+export function BackgroundContent() {
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+
+ const handleBlurSelect = useCallback(
+ async (blurIntensity: number) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "blur", blurIntensity } },
+ });
+ },
+ [editor.project],
+ );
+
+ const handleColorSelect = useCallback(
+ async (color: string) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "color", color } },
+ });
+ },
+ [editor.project],
+ );
+
+ const isBlurBackground = activeProject.settings.background.type === "blur";
+ const isColorBackground = activeProject.settings.background.type === "color";
+
+ const currentBlurIntensity = isBlurBackground
+ ? (activeProject.settings.background as { blurIntensity: number })
+ .blurIntensity
+ : DEFAULT_BLUR_INTENSITY;
+
+ const currentBackgroundColor = isColorBackground
+ ? (activeProject.settings.background as { color: string }).color
+ : DEFAULT_COLOR;
+ const canvasSize = activeProject.settings.canvasSize;
+
+ const blurPreviews = useMemo(
+ () =>
+ BLUR_INTENSITY_PRESETS.map((blur) => (
+ <BlurPreview
+ key={blur.value}
+ blur={blur}
+ canvasSize={canvasSize}
+ isSelected={isBlurBackground && currentBlurIntensity === blur.value}
+ onSelect={() => handleBlurSelect(blur.value)}
+ />
+ )),
+ [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect],
+ );
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>Blur</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">{blurPreviews}</div>
+ </SectionContent>
+ </Section>
+ {COLOR_SECTIONS.map((section) => (
+ <Section
+ key={section.title}
+ collapsible
+ defaultOpen={false}
+ sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
+ >
+ <SectionHeader>
+ <SectionTitle>{section.title}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">
+ <BackgroundPreviews
+ backgrounds={section.backgrounds as string[]}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ onSelect={handleColorSelect}
+ useBackgroundColor={
+ "useBackgroundColor" in section
+ ? section.useBackgroundColor
+ : false
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
new file mode 100644
index 00000000..e0229ea9
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
@@ -0,0 +1,369 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FPS_PRESETS } from "@/constants/project-constants";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { BackgroundContent } from "./background";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { useEditorStore } from "@/stores/editor-store";
+import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Tick02Icon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { formatNumberForDisplay } from "@/utils/math";
+import { OcSquarePlusIcon } from "@opencut/ui/icons";
+import type { TCanvasSize } from "@/lib/project/types";
+
+type SettingsView = "project-info" | "background";
+
+const PRESET_LABELS: Record<string, string> = {
+ "1:1": "1:1",
+ "16:9": "16:9",
+ "9:16": "9:16",
+ "4:3": "4:3",
+};
+
+function areCanvasSizesEqual({
+ left,
+ right,
+}: {
+ left: TCanvasSize;
+ right: TCanvasSize;
+}) {
+ return left.width === right.width && left.height === right.height;
+}
+
+function formatCanvasDimension({ value }: { value: number }) {
+ return formatNumberForDisplay({ value, maxFractionDigits: 0 });
+}
+
+function parseCanvasDimension({ input }: { input: string }): number | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) return null;
+
+ const rounded = Math.round(parsed);
+ return rounded > 0 ? rounded : null;
+}
+
+function useCanvasDimensionDraft({
+ value,
+ onCommit,
+}: {
+ value: number;
+ onCommit: (value: number) => void;
+}) {
+ const pendingValueRef = useRef(value);
+ const syncedValueRef = useRef(value);
+
+ if (syncedValueRef.current !== value) {
+ syncedValueRef.current = value;
+ pendingValueRef.current = value;
+ }
+
+ return usePropertyDraft({
+ displayValue: formatCanvasDimension({ value }),
+ parse: (input) => parseCanvasDimension({ input }),
+ onPreview: (nextValue) => {
+ pendingValueRef.current = nextValue;
+ },
+ onCommit: () => {
+ if (pendingValueRef.current !== value) {
+ onCommit(pendingValueRef.current);
+ }
+ },
+ });
+}
+
+export function SettingsView() {
+ const [view, setView] = useState<SettingsView>("project-info");
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const { canvasPresets } = useEditorStore();
+ const currentCanvasSize = activeProject.settings.canvasSize;
+ const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
+ const lastCustomCanvasSize =
+ activeProject.settings.lastCustomCanvasSize ?? null;
+
+ const presetItems = canvasPresets.map((preset, index) => {
+ const ratio = dimensionToAspectRatio(preset);
+ return {
+ id: index.toString(),
+ label: PRESET_LABELS[ratio] ?? ratio,
+ ratio,
+ canvasSize: preset,
+ };
+ });
+
+ const selectedPresetId = canvasSizeMode === "preset"
+ ? (presetItems.find((preset) =>
+ areCanvasSizesEqual({
+ left: preset.canvasSize,
+ right: currentCanvasSize,
+ }),
+ )?.id ?? null)
+ : null;
+
+ const updateCustomCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateLastCustomCanvasSize =
+ lastCustomCanvasSize === null ||
+ !areCanvasSizesEqual({
+ left: canvasSize,
+ right: lastCustomCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
+
+ if (
+ !shouldUpdateCanvasSize &&
+ !shouldUpdateLastCustomCanvasSize &&
+ !shouldUpdateCanvasSizeMode
+ ) {
+ return;
+ }
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "custom" as const }
+ : {}),
+ lastCustomCanvasSize: canvasSize,
+ },
+ });
+ };
+
+ const selectPresetCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
+
+ if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "preset" as const }
+ : {}),
+ },
+ });
+ };
+
+ const selectCustomCanvasSize = () => {
+ updateCustomCanvasSize({
+ canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
+ });
+ };
+
+ const widthDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.width,
+ onCommit: (width) =>
+ updateCustomCanvasSize({
+ canvasSize: { width, height: currentCanvasSize.height },
+ }),
+ });
+
+ const heightDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.height,
+ onCommit: (height) =>
+ updateCustomCanvasSize({
+ canvasSize: { width: currentCanvasSize.width, height },
+ }),
+ });
+
+ const isCustomSelected = canvasSizeMode === "custom";
+
+ return (
+ <PanelView
+ contentClassName="px-0"
+ scrollClassName="pt-0"
+ actions={
+ <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
+ <TabsList>
+ <TabsTrigger value="project-info">Project info</TabsTrigger>
+ <TabsTrigger value="background">Background</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ }
+ >
+ {view === "project-info" && (
+ <div className="flex flex-col">
+ <Section showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle className="flex-1">Name</SectionTitle>
+ <span className="text-sm truncate">
+ {activeProject.metadata.name}
+ </span>
+ </SectionHeader>
+ </Section>
+ <Section showTopBorder={false}>
+ <SectionHeader className="justify-between">
+ <SectionTitle className="flex-1">Frame rate</SectionTitle>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={(value) => {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ }}
+ >
+ <SelectTrigger className="bg-transparent border-none p-1 h-auto">
+ <SelectValue placeholder="Select a frame rate" />
+ </SelectTrigger>
+ <SelectContent>
+ {FPS_PRESETS.map((preset) => (
+ <SelectItem key={preset.value} value={preset.value}>
+ {preset.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionHeader>
+ </Section>
+ <Section
+ showTopBorder={false}
+ collapsible
+ sectionKey="settings:aspect-ratio"
+ >
+ <SectionHeader>
+ <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
+ </SectionHeader>
+ <SectionContent className="px-2 flex flex-col gap-1 pb-2">
+ {presetItems.map((preset) => (
+ <AspectRatioItem
+ key={preset.id}
+ label={preset.label}
+ previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
+ isSelected={selectedPresetId === preset.id}
+ onClick={() => {
+ selectPresetCanvasSize({
+ canvasSize: preset.canvasSize,
+ });
+ }}
+ />
+ ))}
+ <div className="pb-2">
+ <AspectRatioItem
+ key="custom"
+ label="Custom"
+ previewIcon={<OcSquarePlusIcon />}
+ isSelected={isCustomSelected}
+ onClick={selectCustomCanvasSize}
+ uiOptions={
+ <div className=" flex items-center gap-2 text-foreground">
+ <NumberField
+ value={widthDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas width"
+ onFocus={widthDraft.onFocus}
+ onChange={widthDraft.onChange}
+ onBlur={widthDraft.onBlur}
+ />
+ <NumberField
+ value={heightDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas height"
+ onFocus={heightDraft.onFocus}
+ onChange={heightDraft.onChange}
+ onBlur={heightDraft.onBlur}
+ />
+ </div>
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ </div>
+ )}
+ {view === "background" && <BackgroundContent />}
+ </PanelView>
+ );
+}
+
+function AspectRatioItem({
+ label,
+ previewIcon,
+ isSelected,
+ onClick,
+ uiOptions,
+}: {
+ label: string;
+ previewIcon: React.ReactNode;
+ isSelected: boolean;
+ onClick: () => void;
+ uiOptions?: React.ReactNode;
+}) {
+ return (
+ <Button
+ variant={isSelected ? "secondary" : "ghost"}
+ className={cn(
+ "px-2 py-0 flex flex-col h-fit w-full",
+ !isSelected && "border border-transparent opacity-75!",
+ )}
+ onClick={onClick}
+ >
+ <div className="w-full flex justify-between items-center h-8">
+ <div className="flex-1 flex items-center gap-2">
+ <div className="flex items-center justify-center size-5">
+ {previewIcon}
+ </div>
+ <span className="text-sm truncate">{label}</span>
+ </div>
+ <div>
+ {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
+ </div>
+ </div>
+ {uiOptions && isSelected && (
+ <div className="w-full pb-2">{uiOptions}</div>
+ )}
+ </Button>
+ );
+}
+
+function AspectRatioPreview({ ratio }: { ratio?: string }) {
+ if (!ratio) return null;
+
+ const [w, h] = ratio.split(":").map(Number);
+ const maxSize = 16;
+ const width = w >= h ? maxSize : (w / h) * maxSize;
+ const height = h >= w ? maxSize : (h / w) * maxSize;
+
+ return (
+ <div
+ style={{ width, height, borderWidth: 1.5 }}
+ className="rounded-xs border-current opacity-60"
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
index b1c57c3d..4fb43ac2 100644
--- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -24,7 +24,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { useSoundSearch } from "@/hooks/use-sound-search";
import { useSoundsStore } from "@/stores/sounds-store";
-import type { SavedSound, SoundEffect } from "@/types/sounds";
+import type { SavedSound, SoundEffect } from "@/lib/sounds/types";
import { cn } from "@/utils/ui";
import {
FavouriteIcon,
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
index 671effec..af489630 100644
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -2,81 +2,110 @@
import Image from "next/image";
import type { CSSProperties } from "react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { toast } from "sonner";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import { useEditor } from "@/hooks/use-editor";
+import { resolveStickerIntrinsicSize } from "@/lib/stickers";
import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import {
- resolveStickerId,
- type StickerItem as StickerData,
+ buildGraphicElement,
+ buildStickerElement,
+} from "@/lib/timeline/element-utils";
+import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
+import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
+import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
+import type { TimelineDragData } from "@/lib/timeline/drag";
+import type {
+ StickerBrowseSection,
+ StickerCategory,
+ StickerItem as StickerData,
} from "@/lib/stickers";
import { useStickersStore } from "@/stores/stickers-store";
import { cn } from "@/utils/ui";
import {
HappyIcon,
- ClockIcon,
- MultiplicationSignIcon,
- Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { Spinner } from "@/components/ui/spinner";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { OcSlidersVerticalIcon } from "@opencut/ui/icons";
-import type { StickerCategory } from "@/types/stickers";
-import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
-import { parseStickerId } from "@/lib/stickers/sticker-id";
export function StickersView() {
- const { selectedCategory, setSelectedCategory } = useStickersStore();
+ const {
+ browseContent,
+ browseStickers,
+ searchQuery,
+ searchStickers,
+ selectedCategory,
+ setSearchQuery,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ useEffect(() => {
+ if (viewMode === "browse" && !browseContent) {
+ void browseStickers();
+ }
+ }, [browseContent, browseStickers, viewMode]);
return (
- <PanelView
- title="Stickers"
- actions={
- <div className="flex items-center">
- <Select
- value={selectedCategory}
- onValueChange={(value: StickerCategory) =>
- setSelectedCategory({ category: value })
- }
+ <div className="flex h-full flex-col py-2">
+ <div className="px-2">
+ <Input
+ size="sm"
+ variant="default"
+ placeholder="Search..."
+ value={searchQuery}
+ onChange={(e) => {
+ setSearchQuery({ query: e.target.value });
+ void searchStickers({ query: e.target.value });
+ }}
+ showClearIcon
+ onClear={() => {
+ setSearchQuery({ query: "" });
+ void searchStickers({ query: "" });
+ }}
+ className="w-full"
+ containerClassName="w-full"
+ />
+ </div>
+
+ <div className="mt-2 flex min-h-0 flex-1 flex-col">
+ <div className="border-b border-border px-2">
+ <div
+ role="tablist"
+ aria-label="Sticker categories"
+ className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
>
- <SelectTrigger variant="outline" size="sm" className="mr-1.5">
- <SelectValue placeholder="All" />
- </SelectTrigger>
- <SelectContent>
- {Object.entries(STICKER_CATEGORIES).map(([category, label]) => (
- <SelectItem key={category} value={category}>
+ {Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
+ const isActive = key === selectedCategory;
+ return (
+ <button
+ key={key}
+ type="button"
+ role="tab"
+ aria-selected={isActive}
+ onClick={() =>
+ setSelectedCategory({
+ category: key as StickerCategory,
+ })
+ }
+ className={cn(
+ "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
+ isActive && "text-primary border-primary",
+ )}
+ >
{label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
-
- <Button variant="ghost" size="icon">
- <HugeiconsIcon icon={Search01Icon} className="!size-3.5" />
- </Button>
-
- <Button variant="ghost" size="icon">
- <OcSlidersVerticalIcon className="!size-3.5" />
- </Button>
+ </button>
+ );
+ })}
+ </div>
</div>
- }
- >
- <StickersContentView />
- </PanelView>
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
+ <StickersContentView />
+ </div>
+ </div>
+ </div>
);
}
@@ -92,10 +121,10 @@ function StickerGrid({
"--sticker-max"?: string;
} = {
gridTemplateColumns: shouldCapSize
- ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
- : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
- "--sticker-min": "96px",
- ...(shouldCapSize ? { "--sticker-max": "160px" } : {}),
+ ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
+ : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
+ "--sticker-min": "80px",
+ ...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
};
return (
@@ -107,6 +136,18 @@ function StickerGrid({
);
}
+function StickerRow({ items }: { items: StickerData[] }) {
+ return (
+ <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden">
+ {items.map((item) => (
+ <div key={item.id} className="w-20 shrink-0">
+ <StickerItem item={item} shouldCapSize containerClassName="w-full" />
+ </div>
+ ))}
+ </div>
+ );
+}
+
function EmptyView({ message }: { message: string }) {
return (
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
@@ -122,88 +163,170 @@ function EmptyView({ message }: { message: string }) {
);
}
+function RegionBanner({ region }: { region: string }) {
+ return (
+ <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2">
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ className="shrink-0 text-sky-600"
+ aria-hidden="true"
+ >
+ <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
+ <circle cx="12" cy="10" r="3" />
+ </svg>
+ <span className="text-xs font-semibold text-sky-600">{region}</span>
+ </div>
+ );
+}
+
function StickersContentView() {
const {
+ browseContent,
+ clearRecentStickers,
+ isBrowsing,
+ isSearching,
searchQuery,
- viewMode,
searchResults,
- recentStickers,
- isSearching,
- clearRecentStickers,
+ selectedCategory,
+ setSelectedCategory,
+ viewMode,
} = useStickersStore();
- const itemsToDisplay = useMemo(() => {
- if (viewMode === "search" && searchResults) {
- return searchResults.items;
+ if (viewMode === "search") {
+ if (isSearching) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
}
- return [];
- }, [viewMode, searchResults]);
+ if (searchResults?.items.length) {
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ const isRegionSearch =
+ selectedCategory === "flags" &&
+ resolveQueryToRegions({ query: normalizedQuery }) !== null;
+ const regionLabel = getRegionLabel({ query: normalizedQuery });
- const recentStickerItems = useMemo(() => {
- const items: StickerData[] = [];
- for (const stickerId of recentStickers) {
- const recentStickerItem = toRecentStickerItem({ stickerId });
- if (recentStickerItem) {
- items.push(recentStickerItem);
- }
+ return (
+ <div className="flex flex-col gap-3 pb-4">
+ {isRegionSearch && <RegionBanner region={regionLabel} />}
+ <div className="flex items-center justify-between">
+ <span className="text-muted-foreground text-sm">
+ {searchResults.total} results
+ </span>
+ </div>
+ <StickerGrid items={searchResults.items} />
+ </div>
+ );
}
- return items;
- }, [recentStickers]);
+
+ // "all" tab search — sections are in browseContent, fall through to section rendering below
+ if (selectedCategory !== "all" && searchQuery) {
+ return <EmptyView message={`No stickers found for "${searchQuery}"`} />;
+ }
+ }
+
+ if (isBrowsing && !browseContent) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
+ }
+
+ if (!browseContent?.sections.length) {
+ const categoryLabel = STICKER_CATEGORIES[selectedCategory];
+ return (
+ <EmptyView
+ message={
+ viewMode === "search"
+ ? `No stickers found for "${searchQuery}"`
+ : selectedCategory === "all"
+ ? "No stickers available yet."
+ : `No stickers available in ${categoryLabel.toLowerCase()} yet.`
+ }
+ />
+ );
+ }
return (
- <div className="flex h-full flex-col gap-4">
- {recentStickerItems.length > 0 && viewMode === "browse" && (
- <div className="flex h-full flex-col gap-2">
- <div className="flex items-center gap-2">
- <HugeiconsIcon
- icon={ClockIcon}
- className="text-muted-foreground size-4"
- />
- <span className="text-sm font-medium">Recent</span>
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <button
- type="button"
- onClick={clearRecentStickers}
- className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
- >
- <HugeiconsIcon
- icon={MultiplicationSignIcon}
- className="text-muted-foreground size-3"
- />
- </button>
- </TooltipTrigger>
- <TooltipContent>
- <p>Clear recent stickers</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
+ <div className="flex flex-col gap-4 pb-4">
+ {browseContent.sections.map((section) => (
+ <StickerSection
+ key={section.id}
+ section={section}
+ onClearRecent={clearRecentStickers}
+ onSeeAll={(category) => {
+ setSelectedCategory({ category });
+ }}
+ />
+ ))}
+ </div>
+ );
+}
+
+function StickerSection({
+ section,
+ onClearRecent,
+ onSeeAll,
+}: {
+ section: StickerBrowseSection;
+ onClearRecent: () => void;
+ onSeeAll: (category: StickerCategory) => void;
+}) {
+ const hasHeader =
+ Boolean(section.title) || section.id === "recent" || section.action;
+
+ return (
+ <div className="flex flex-col gap-2">
+ {hasHeader && (
+ <div className="flex items-center justify-between gap-2">
+ {section.title ? (
+ <p className="text-xs text-muted-foreground">{section.title}</p>
+ ) : (
+ <div />
+ )}
+
+ <div className="ml-auto flex items-center gap-2">
+ {section.id === "recent" && (
+ <Button
+ onClick={onClearRecent}
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-muted-foreground"
+ >
+ Clear
+ </Button>
+ )}
+
+ {section.action?.type === "see-all" && section.action.category && (
+ <Button
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-primary"
+ onClick={() => {
+ onSeeAll(section.action?.category as StickerCategory);
+ }}
+ >
+ See all
+ </Button>
+ )}
</div>
- <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize />
</div>
)}
- {viewMode === "search" && (
- <div className="h-full">
- {isSearching ? (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- ) : searchResults?.items.length ? (
- <div className="flex flex-col gap-3">
- <div className="flex items-center justify-between">
- <span className="text-muted-foreground text-sm">
- {searchResults.total} results
- </span>
- </div>
- <StickerGrid items={itemsToDisplay} shouldCapSize />
- </div>
- ) : searchQuery ? (
- <EmptyView message={`No stickers found for "${searchQuery}"`} />
- ) : null}
- </div>
+ {section.layout === "row" ? (
+ <StickerRow items={section.items} />
+ ) : (
+ <StickerGrid items={section.items} />
)}
</div>
);
@@ -212,65 +335,116 @@ function StickersContentView() {
interface StickerItemProps {
item: StickerData;
shouldCapSize?: boolean;
+ containerClassName?: string;
}
-function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
- const { addingSticker, addStickerToTimeline } = useStickersStore();
- const isAdding = addingSticker === item.id;
+function StickerItem({
+ item,
+ shouldCapSize = false,
+ containerClassName,
+}: StickerItemProps) {
+ const editor = useEditor();
+ const { addToRecentStickers } = useStickersStore();
+ const [isAdding, setIsAdding] = useState(false);
const [hasImageError, setHasImageError] = useState(false);
useEffect(() => {
if (!item.id) {
return;
}
+
setHasImageError(false);
}, [item.id]);
const displayName = item.name;
+ const shapePreset =
+ item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
const handleAdd = async () => {
+ setIsAdding(true);
try {
- await addStickerToTimeline({
- stickerId: item.id,
- name: item.name,
+ const currentTime = editor.playback.getCurrentTime();
+
+ let element:
+ | ReturnType<typeof buildGraphicElement>
+ | ReturnType<typeof buildStickerElement>;
+ if (shapePreset) {
+ element = buildGraphicElement({
+ definitionId: shapePreset.definitionId,
+ name: shapePreset.name,
+ startTime: currentTime,
+ params: shapePreset.params,
+ });
+ } else {
+ const { width: intrinsicWidth, height: intrinsicHeight } =
+ await resolveStickerIntrinsicSize({ stickerId: item.id });
+ element = buildStickerElement({
+ stickerId: item.id,
+ name: item.name,
+ startTime: currentTime,
+ intrinsicWidth,
+ intrinsicHeight,
+ });
+ }
+
+ editor.timeline.insertElement({
+ placement: { mode: "auto" },
+ element,
});
+
+ addToRecentStickers({ stickerId: item.id });
} catch (error) {
console.error("Failed to add sticker:", error);
toast.error("Failed to add sticker to timeline");
+ } finally {
+ setIsAdding(false);
}
};
- const preview = hasImageError ? (
- <div className="flex size-full items-center justify-center p-2">
- <span className="text-muted-foreground text-center text-xs break-all">
- {displayName}
- </span>
- </div>
- ) : (
- <div className="flex size-full items-center justify-center p-4">
- <Image
- src={item.previewUrl}
- alt={displayName}
- width={64}
- height={64}
- className="size-full object-contain"
- style={
- shouldCapSize
- ? {
- maxWidth: "var(--sticker-max, 160px)",
- maxHeight: "var(--sticker-max, 160px)",
- }
- : undefined
- }
- onError={() => {
- setHasImageError(true);
- }}
- loading="lazy"
- unoptimized
- />
+ const preview = (
+ <div className="flex size-full items-center justify-center p-3">
+ {hasImageError ? (
+ <span className="text-muted-foreground text-center text-xs break-all">
+ {displayName}
+ </span>
+ ) : (
+ <Image
+ src={item.previewUrl}
+ alt={displayName}
+ width={64}
+ height={64}
+ className="size-full object-contain"
+ style={
+ shouldCapSize
+ ? {
+ maxWidth: "var(--sticker-max, 160px)",
+ maxHeight: "var(--sticker-max, 160px)",
+ }
+ : undefined
+ }
+ onError={() => setHasImageError(true)}
+ loading="lazy"
+ unoptimized
+ />
+ )}
</div>
);
+ const dragData: TimelineDragData = shapePreset
+ ? {
+ id: item.id,
+ type: "graphic",
+ name: displayName,
+ definitionId: shapePreset.definitionId,
+ params: shapePreset.params ?? {},
+ }
+ : {
+ id: item.id,
+ type: "sticker",
+ name: displayName,
+ stickerId: item.id,
+ };
+
return (
<div
className={cn("relative", isAdding && "pointer-events-none opacity-50")}
@@ -278,18 +452,13 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
<DraggableItem
name={displayName}
preview={preview}
- dragData={{
- id: item.id,
- type: "sticker",
- name: displayName,
- stickerId: item.id,
- }}
+ dragData={dragData}
onAddToTimeline={handleAdd}
aspectRatio={1}
shouldShowLabel={false}
isRounded
variant="card"
- containerClassName="w-full"
+ containerClassName={containerClassName ?? "w-full"}
/>
{isAdding && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
@@ -299,36 +468,3 @@ function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
</div>
);
}
-
-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;
-}): StickerData | 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;
- }
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx
index 3673acc3..075cc25b 100644
--- a/apps/web/src/components/editor/panels/assets/views/text.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -1,7 +1,7 @@
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { useEditor } from "@/hooks/use-editor";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
import { buildTextElement } from "@/lib/timeline/element-utils";
export function TextView() {
@@ -12,7 +12,7 @@ export function TextView() {
if (!activeScene) return;
const element = buildTextElement({
- raw: DEFAULT_TEXT_ELEMENT,
+ raw: DEFAULTS.text.element,
startTime: currentTime,
});
@@ -33,9 +33,9 @@ export function TextView() {
}
dragData={{
id: "temp-text-id",
- type: DEFAULT_TEXT_ELEMENT.type,
- name: DEFAULT_TEXT_ELEMENT.name,
- content: DEFAULT_TEXT_ELEMENT.content,
+ type: DEFAULTS.text.element.type,
+ name: DEFAULTS.text.element.name,
+ content: DEFAULTS.text.element.content,
}}
aspectRatio={1}
onAddToTimeline={handleAddToTimeline}
diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx
index d4bcdcae..13226378 100644
--- a/apps/web/src/components/editor/panels/preview/context-menu.tsx
+++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx
@@ -4,9 +4,12 @@ import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
+ ContextMenuSeparator,
} from "@/components/ui/context-menu";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor";
import { usePreviewStore } from "@/stores/preview-store";
+import { toast } from "sonner";
export function PreviewContextMenu({
onToggleFullscreen,
@@ -16,25 +19,55 @@ export function PreviewContextMenu({
containerRef: React.RefObject<HTMLElement | null>;
}) {
const editor = useEditor();
+ const viewport = usePreviewViewport();
const { overlays, setOverlayVisibility } = usePreviewStore();
+ const handleCopySnapshot = async () => {
+ const result = await editor.renderer.copySnapshot();
+
+ if (!result.success) {
+ toast.error("Failed to copy snapshot", {
+ description: result.error ?? "Please try again",
+ });
+ return;
+ }
+ };
+
+ const handleSaveSnapshot = async () => {
+ const result = await editor.renderer.saveSnapshot();
+
+ if (!result.success) {
+ toast.error("Failed to save snapshot", {
+ description: result.error ?? "Please try again",
+ });
+ return;
+ }
+ };
+
return (
<ContextMenuContent className="w-56" container={containerRef.current}>
+ <ContextMenuItem onClick={viewport.fitToScreen} inset>
+ Fit to screen
+ </ContextMenuItem>
+ <ContextMenuSeparator />
<ContextMenuItem onClick={onToggleFullscreen} inset>
Full screen
</ContextMenuItem>
- <ContextMenuItem onClick={() => editor.renderer.saveSnapshot()} inset>
+ <ContextMenuItem onClick={handleSaveSnapshot} inset>
Save snapshot
</ContextMenuItem>
+ <ContextMenuItem onClick={handleCopySnapshot} inset>
+ Copy snapshot
+ </ContextMenuItem>
+ <ContextMenuSeparator />
<ContextMenuCheckboxItem
checked={overlays.bookmarks}
onCheckedChange={(checked) =>
setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
}
>
- Show bookmarks
+ Show bookmark notes
</ContextMenuCheckboxItem>
- <ContextMenuItem inset>Show grid</ContextMenuItem>
</ContextMenuContent>
);
}
diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx
new file mode 100644
index 00000000..ad7a621f
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { getGuideById } from "@/lib/guides";
+import { usePreviewStore } from "@/stores/preview-store";
+import { usePreviewViewport } from "./preview-viewport";
+
+export function GuideOverlay() {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const viewport = usePreviewViewport();
+ const guide = getGuideById(activeGuide);
+
+ if (!guide) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ left: viewport.sceneLeft,
+ top: viewport.sceneTop,
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ }}
+ >
+ {guide.renderOverlay({
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx
new file mode 100644
index 00000000..e6d3df51
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/guide-popover.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { AnimatePresence, motion } from "motion/react";
+import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides";
+import { usePreviewStore } from "@/stores/preview-store";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/utils/ui";
+
+export function GridPopover({ children }: { children: React.ReactNode }) {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const toggleGuide = usePreviewStore((state) => state.toggleGuide);
+ const activeGuideDef = getGuideById(activeGuide);
+ const options = activeGuideDef?.renderOptions?.();
+
+ return (
+ <Popover>
+ <PopoverTrigger>{children}</PopoverTrigger>
+ <PopoverContent sideOffset={8} className="w-60 px-0">
+ <div className="flex flex-col gap-2 px-4">
+ <Label>Guides</Label>
+ <div className="grid grid-cols-3 gap-1">
+ {GUIDE_REGISTRY.map((guide) => (
+ <GridItem
+ key={guide.id}
+ label={guide.label}
+ preview={guide.renderPreview()}
+ isSelected={activeGuide === guide.id}
+ onClick={() => toggleGuide(guide.id)}
+ />
+ ))}
+ </div>
+ </div>
+ <AnimatePresence>
+ {options && (
+ <motion.div
+ initial={{ height: 0, opacity: 0 }}
+ animate={{ height: "auto", opacity: 1 }}
+ exit={{ height: 0, opacity: 0 }}
+ transition={{
+ height: { type: "spring", duration: 0.2, bounce: 0 },
+ opacity: { duration: 0.2 },
+ }}
+ className="overflow-hidden"
+ >
+ <Separator className="my-3" />
+ <div className="px-4">{options}</div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function GridItem({
+ label,
+ preview,
+ isSelected,
+ onClick,
+}: {
+ label: string;
+ preview: React.ReactNode;
+ isSelected?: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ aria-pressed={isSelected}
+ className={cn(
+ "flex cursor-pointer rounded-sm flex-col gap-2 px-1.5 py-1.5 hover:bg-foreground/5",
+ isSelected && "bg-primary/5! text-primary",
+ )}
+ >
+ <div
+ className={cn(
+ "aspect-video bg-foreground/5 flex items-center justify-center rounded-sm",
+ isSelected && "bg-primary/5!",
+ )}
+ >
+ {preview}
+ </div>
+ <span
+ className={cn(
+ "text-xs",
+ isSelected ? "text-primary" : "text-muted-foreground",
+ )}
+ >
+ {label}
+ </span>
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
new file mode 100644
index 00000000..44b8a7f1
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx
@@ -0,0 +1,332 @@
+"use client";
+
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import type { IconSvgElement } from "@hugeicons/react";
+
+export const HANDLE_SIZE = 10;
+export const HANDLE_HIT_AREA_SIZE = 18;
+export const ICON_HANDLE_RADIUS = 10;
+export const EDGE_HANDLE_THIN_SIZE = 6;
+export const EDGE_HANDLE_THICK_SIZE = 14;
+export const LINE_HIT_AREA_SIZE = 48;
+
+export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
+ const normalized = ((angleDeg % 180) + 180) % 180;
+ if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
+ if (normalized < 67.5) return "cursor-nwse-resize";
+ if (normalized < 112.5) return "cursor-ns-resize";
+ return "cursor-nesw-resize";
+}
+
+export function HandleButton({
+ screen,
+ cursor,
+ hitAreaSize,
+ className,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ children,
+}: {
+ screen: { x: number; y: number };
+ cursor?: string;
+ hitAreaSize: number;
+ className?: string;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+ children: React.ReactNode;
+}) {
+ return (
+ <button
+ type="button"
+ className={cn(
+ "absolute flex items-center justify-center outline-none",
+ cursor,
+ className,
+ )}
+ style={{
+ left: screen.x - hitAreaSize / 2,
+ top: screen.y - hitAreaSize / 2,
+ width: hitAreaSize,
+ height: hitAreaSize,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
+ onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
+ >
+ {children}
+ </button>
+ );
+}
+
+export function CornerHandle({
+ cursor,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ cursor?: string;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
+ />
+ </HandleButton>
+ );
+}
+
+export function EdgeHandle({
+ edge,
+ screen,
+ rotation,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ edge: "right" | "left" | "bottom";
+ screen: { x: number; y: number };
+ rotation: number;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ const isHorizontalEdge = edge === "right" || edge === "left";
+ const width = isHorizontalEdge
+ ? EDGE_HANDLE_THIN_SIZE
+ : EDGE_HANDLE_THICK_SIZE;
+ const height = isHorizontalEdge
+ ? EDGE_HANDLE_THICK_SIZE
+ : EDGE_HANDLE_THIN_SIZE;
+ const cursor = getResizeCursor({
+ angleDeg: isHorizontalEdge ? rotation : rotation + 90,
+ });
+
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width, height, transform: `rotate(${rotation}deg)` }}
+ />
+ </HandleButton>
+ );
+}
+
+export function IconHandle({
+ icon,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ icon: IconSvgElement;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ hitAreaSize={ICON_HANDLE_RADIUS * 2}
+ className="rounded-full bg-white text-black shadow-sm"
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
+ </HandleButton>
+ );
+}
+
+export function BoundingBoxOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ cursor,
+ dashed = false,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ cursor?: string;
+ dashed?: boolean;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className={cn("absolute overflow-visible", cursor)}
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ pointerEvents: onPointerDown ? "auto" : "none",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <rect
+ x={0.5}
+ y={0.5}
+ width={Math.max(outlineWidth - 1, 0)}
+ height={Math.max(outlineHeight - 1, 0)}
+ fill="transparent"
+ stroke="white"
+ strokeDasharray={dashed ? "4 4" : undefined}
+ strokeOpacity={0.75}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "all" : "none" }}
+ />
+ </svg>
+ );
+}
+
+export function ShapeOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ pathData,
+ cursor,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ pathData: string;
+ cursor?: string;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className={cn("absolute overflow-visible", cursor)}
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ pointerEvents: onPointerDown ? "auto" : "none",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <path
+ d={pathData}
+ fill="transparent"
+ stroke="white"
+ strokeOpacity={0.75}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "all" : "none" }}
+ />
+ </svg>
+ );
+}
+
+export function LineOverlay({
+ start,
+ end,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ const dx = end.x - start.x;
+ const dy = end.y - start.y;
+ const length = Math.sqrt(dx * dx + dy * dy);
+ const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
+ const cx = (start.x + end.x) / 2;
+ const cy = (start.y + end.y) / 2;
+
+ const sharedStyle = {
+ left: cx - length / 2,
+ width: length,
+ transform: `rotate(${angleDeg}deg)`,
+ transformOrigin: "center center",
+ };
+
+ return (
+ <>
+ {onPointerDown && (
+ <div
+ className="absolute"
+ style={{
+ ...sharedStyle,
+ top: cy - LINE_HIT_AREA_SIZE / 2,
+ height: LINE_HIT_AREA_SIZE,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ />
+ )}
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ ...sharedStyle,
+ top: cy - 0.5,
+ height: 1,
+ backgroundColor: "white",
+ opacity: 0.75,
+ }}
+ />
+ </>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
index df60db01..c625b9bf 100644
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useMemo, useRef } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/hooks/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop";
@@ -12,50 +12,70 @@ import { buildScene } from "@/services/renderer/scene-builder";
import { getLastFrameTime } from "@/lib/time";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
+import { GuideOverlay } from "./guide-overlay";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { usePreviewStore } from "@/stores/preview-store";
import { PreviewContextMenu } from "./context-menu";
import { PreviewToolbar } from "./toolbar";
+import {
+ PreviewViewportProvider,
+ usePreviewViewportState,
+} from "./preview-viewport";
function usePreviewSize() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const canvasSize = useEditor(
+ (e) => e.project.getActive()?.settings.canvasSize,
+ );
return {
- width: activeProject?.settings.canvasSize.width,
- height: activeProject?.settings.canvasSize.height,
+ width: canvasSize?.width,
+ height: canvasSize?.height,
};
}
+function normalizeWheelDelta({
+ delta,
+ deltaMode,
+ pageSize,
+}: {
+ delta: number;
+ deltaMode: number;
+ pageSize: number;
+}): number {
+ if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
+ return delta * 16;
+ }
+
+ if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
+ return delta * pageSize;
+ }
+
+ return delta;
+}
+
export function PreviewPanel() {
const containerRef = useRef<HTMLDivElement>(null);
- const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
+ const { toggleFullscreen } = useFullscreen({ containerRef });
return (
<div
ref={containerRef}
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
>
- <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
- <PreviewCanvas
- onToggleFullscreen={toggleFullscreen}
- containerRef={containerRef}
- />
- <RenderTreeController />
- </div>
- <PreviewToolbar
- isFullscreen={isFullscreen}
+ <PreviewCanvas
+ containerRef={containerRef}
onToggleFullscreen={toggleFullscreen}
/>
+ <RenderTreeController />
</div>
);
}
function RenderTreeController() {
const editor = useEditor();
- const tracks = editor.timeline.getTracks();
- const mediaAssets = editor.media.getAssets();
- const activeProject = editor.project.getActive();
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const activeProject = useEditor((e) => e.project.getActive());
const { width, height } = usePreviewSize();
@@ -79,23 +99,30 @@ function RenderTreeController() {
}
function PreviewCanvas({
- onToggleFullscreen,
containerRef,
+ onToggleFullscreen,
}: {
- onToggleFullscreen: () => void;
containerRef: React.RefObject<HTMLElement | null>;
+ onToggleFullscreen: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
- const outerContainerRef = useRef<HTMLDivElement>(null);
- const canvasBoundsRef = useRef<HTMLDivElement>(null);
+ const viewportRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(null);
const renderingRef = useRef(false);
const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
- const containerSize = useContainerSize({ containerRef: outerContainerRef });
+ const viewportSize = useContainerSize({ containerRef: viewportRef });
const editor = useEditor();
- const activeProject = editor.project.getActive();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const renderTree = useEditor((e) => e.renderer.getRenderTree());
const { overlays } = usePreviewStore();
+ const viewport = usePreviewViewportState({
+ canvasHeight: nativeHeight,
+ canvasWidth: nativeWidth,
+ viewportHeight: viewportSize.height,
+ viewportRef,
+ viewportWidth: viewportSize.width,
+ });
const renderer = useMemo(() => {
return new CanvasRenderer({
@@ -105,37 +132,6 @@ function PreviewCanvas({
});
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
- const displaySize = useMemo(() => {
- if (
- !nativeWidth ||
- !nativeHeight ||
- containerSize.width === 0 ||
- containerSize.height === 0
- ) {
- return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 };
- }
-
- const paddingBuffer = 4;
- const availableWidth = containerSize.width - paddingBuffer;
- const availableHeight = containerSize.height - paddingBuffer;
-
- const aspectRatio = nativeWidth / nativeHeight;
- const containerAspect = availableWidth / availableHeight;
-
- const displayWidth =
- containerAspect > aspectRatio
- ? availableHeight * aspectRatio
- : availableWidth;
- const displayHeight =
- containerAspect > aspectRatio
- ? availableHeight
- : availableWidth / aspectRatio;
-
- return { width: displayWidth, height: displayHeight };
- }, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]);
-
- const renderTree = editor.renderer.getRenderTree();
-
const render = useCallback(() => {
if (canvasRef.current && renderTree && !renderingRef.current) {
const time = editor.playback.getCurrentTime();
@@ -168,44 +164,130 @@ function PreviewCanvas({
useRafLoop(render);
+ useEffect(() => {
+ const container = viewportRef.current;
+ if (!container) return;
+
+ let pendingZoomDelta = 0;
+ let pendingPanDeltaX = 0;
+ let pendingPanDeltaY = 0;
+ let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null;
+ let panRafId: ReturnType<typeof requestAnimationFrame> | null = null;
+
+ const onWheel = (event: WheelEvent) => {
+ const normalizedDeltaX = normalizeWheelDelta({
+ delta: event.deltaX,
+ deltaMode: event.deltaMode,
+ pageSize: container.clientWidth,
+ });
+ const normalizedDeltaY = normalizeWheelDelta({
+ delta: event.deltaY,
+ deltaMode: event.deltaMode,
+ pageSize: container.clientHeight,
+ });
+ const isZoomGesture = event.ctrlKey || event.metaKey;
+ if (isZoomGesture) {
+ event.preventDefault();
+ pendingZoomDelta += normalizedDeltaY;
+
+ if (zoomRafId === null) {
+ zoomRafId = requestAnimationFrame(() => {
+ const cappedDelta =
+ Math.sign(pendingZoomDelta) *
+ Math.min(Math.abs(pendingZoomDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+
+ viewport.scaleZoom({ factor: zoomFactor });
+ pendingZoomDelta = 0;
+ zoomRafId = null;
+ });
+ }
+
+ return;
+ }
+
+ if (!viewport.canPan) {
+ return;
+ }
+
+ if (normalizedDeltaX === 0 && normalizedDeltaY === 0) {
+ return;
+ }
+
+ event.preventDefault();
+ pendingPanDeltaX += normalizedDeltaX;
+ pendingPanDeltaY += normalizedDeltaY;
+
+ if (panRafId === null) {
+ panRafId = requestAnimationFrame(() => {
+ viewport.panByScreenDelta({
+ deltaX: pendingPanDeltaX,
+ deltaY: pendingPanDeltaY,
+ });
+ pendingPanDeltaX = 0;
+ pendingPanDeltaY = 0;
+ panRafId = null;
+ });
+ }
+ };
+
+ container.addEventListener("wheel", onWheel, {
+ capture: true,
+ passive: false,
+ });
+
+ return () => {
+ container.removeEventListener("wheel", onWheel, {
+ capture: true,
+ });
+ if (zoomRafId !== null) {
+ cancelAnimationFrame(zoomRafId);
+ }
+ if (panRafId !== null) {
+ cancelAnimationFrame(panRafId);
+ }
+ };
+ }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]);
+
return (
- <div
- ref={outerContainerRef}
- className="relative flex size-full items-center justify-center"
- >
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- ref={canvasBoundsRef}
- className="relative"
- style={{ width: displaySize.width, height: displaySize.height }}
- >
- <canvas
- ref={canvasRef}
- width={nativeWidth}
- height={nativeHeight}
- className="block border"
- style={{
- width: displaySize.width,
- height: displaySize.height,
- background:
- activeProject.settings.background.type === "blur"
- ? "transparent"
- : activeProject?.settings.background.color,
- }}
+ <PreviewViewportProvider value={viewport}>
+ <div className="flex size-full min-h-0 min-w-0 flex-col">
+ <div className="flex min-h-0 min-w-0 flex-1 p-2 pb-0">
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ ref={viewportRef}
+ className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
+ >
+ <canvas
+ ref={canvasRef}
+ width={nativeWidth}
+ height={nativeHeight}
+ className="absolute block border"
+ style={{
+ left: viewport.sceneLeft,
+ top: viewport.sceneTop,
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ background:
+ activeProject.settings.background.type === "blur"
+ ? "transparent"
+ : activeProject?.settings.background.color,
+ }}
+ />
+ <GuideOverlay />
+ <PreviewInteractionOverlay />
+ {overlays.bookmarks && <BookmarkNoteOverlay />}
+ </div>
+ </ContextMenuTrigger>
+ <PreviewContextMenu
+ onToggleFullscreen={onToggleFullscreen}
+ containerRef={containerRef}
/>
- <PreviewInteractionOverlay
- canvasRef={canvasRef}
- containerRef={canvasBoundsRef}
- />
- {overlays.bookmarks && <BookmarkNoteOverlay />}
- </div>
- </ContextMenuTrigger>
- <PreviewContextMenu
- onToggleFullscreen={onToggleFullscreen}
- containerRef={containerRef}
- />
- </ContextMenu>
- </div>
+ </ContextMenu>
+ </div>
+ <PreviewToolbar onToggleFullscreen={onToggleFullscreen} />
+ </div>
+ </PreviewViewportProvider>
);
}
diff --git a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx
deleted file mode 100644
index 44efb4c8..00000000
--- a/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-"use client";
-
-import { usePreviewStore } from "@/stores/preview-store";
-import Image from "next/image";
-
-function TikTokGuide() {
- return (
- <div className="pointer-events-none absolute inset-0">
- <Image
- src="/platform-guides/tiktok-blueprint.png"
- alt="TikTok layout guide"
- className="absolute inset-0 size-full object-contain"
- draggable={false}
- fill
- />
- </div>
- );
-}
-
-export function LayoutGuideOverlay() {
- const { layoutGuide } = usePreviewStore();
-
- if (layoutGuide.platform === null) return null;
- if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
-
- return null;
-}
diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx
new file mode 100644
index 00000000..0c11f27a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/mask-handles.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useMaskHandles } from "@/hooks/use-mask-handles";
+import { masksRegistry } from "@/lib/masks";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+import type { ParamValues } from "@/lib/params";
+import type { RectangleMaskParams } from "@/lib/masks/types";
+import {
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ LineOverlay,
+ BoundingBoxOutline,
+ ShapeOutline,
+} from "./handle-primitives";
+import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
+
+function hasRectangleOutlineParams(
+ params: ParamValues,
+): params is RectangleMaskParams {
+ return (
+ typeof params.centerX === "number" &&
+ typeof params.centerY === "number" &&
+ typeof params.width === "number" &&
+ typeof params.height === "number"
+ );
+}
+
+export function MaskHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}) {
+ const viewport = usePreviewViewport();
+ const {
+ selectedWithMask,
+ handlePositions,
+ linePoints,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ } = useMaskHandles({ onSnapLinesChange });
+
+ if (!selectedWithMask) return null;
+
+ const toOverlay = ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) =>
+ viewport.canvasToOverlay({
+ canvasX,
+ canvasY,
+ });
+
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+ const { bounds } = selectedWithMask;
+ const maskRotation = selectedWithMask.mask.params.rotation;
+
+ const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
+
+ const rectangleOutlineProps = hasRectangleOutlineParams(
+ selectedWithMask.mask.params,
+ )
+ ? {
+ center: toOverlay({
+ canvasX:
+ bounds.cx + selectedWithMask.mask.params.centerX * bounds.width,
+ canvasY:
+ bounds.cy + selectedWithMask.mask.params.centerY * bounds.height,
+ }),
+ outlineWidth:
+ selectedWithMask.mask.params.width * bounds.width * scaleX,
+ outlineHeight:
+ selectedWithMask.mask.params.height * bounds.height * scaleY,
+ rotation: maskRotation,
+ }
+ : null;
+
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
+ return (
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ aria-hidden
+ >
+ {def.overlayShape === "line" && linePoints && (
+ <LineOverlay
+ start={toOverlay({
+ canvasX: linePoints.start.x,
+ canvasY: linePoints.start.y,
+ })}
+ end={toOverlay({
+ canvasX: linePoints.end.x,
+ canvasY: linePoints.end.y,
+ })}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: "position" })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ )}
+ {def.overlayShape === "box" && rectangleOutlineProps && (
+ def.buildOverlayPath ? (
+ <>
+ <BoundingBoxOutline {...rectangleOutlineProps} dashed />
+ <ShapeOutline
+ {...rectangleOutlineProps}
+ pathData={def.buildOverlayPath({
+ width: rectangleOutlineProps.outlineWidth,
+ height: rectangleOutlineProps.outlineHeight,
+ })}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: "position" })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ </>
+ ) : (
+ <BoundingBoxOutline
+ {...rectangleOutlineProps}
+ cursor="cursor-move"
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: "position" })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ )
+ )}
+ {handlePositions.map((handle) => {
+ const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
+
+ if (handle.id === "rotation") {
+ return (
+ <IconHandle
+ key={handle.id}
+ icon={Rotate01Icon}
+ screen={screen}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.id === "feather") {
+ return (
+ <IconHandle
+ key={handle.id}
+ icon={FeatherIcon}
+ screen={screen}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.id === "right" || handle.id === "left") {
+ return (
+ <EdgeHandle
+ key={handle.id}
+ edge="right"
+ screen={screen}
+ rotation={maskRotation}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (handle.id === "bottom" || handle.id === "top") {
+ return (
+ <EdgeHandle
+ key={handle.id}
+ edge="bottom"
+ screen={screen}
+ rotation={maskRotation}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ if (
+ handle.id === "top-left" ||
+ handle.id === "top-right" ||
+ handle.id === "bottom-left" ||
+ handle.id === "bottom-right" ||
+ handle.id === "scale"
+ ) {
+ return (
+ <CornerHandle
+ key={handle.id}
+ screen={screen}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
+
+ return (
+ <CornerHandle
+ key={handle.id}
+ cursor={handle.cursor}
+ screen={screen}
+ onPointerDown={(event) =>
+ handlePointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
index 4c33a1a1..65c11726 100644
--- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
@@ -1,54 +1,117 @@
+import { useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
+import type { SnapLine } from "@/lib/preview/preview-snap";
import { TransformHandles } from "./transform-handles";
+import { MaskHandles } from "./mask-handles";
import { SnapGuides } from "./snap-guides";
import { TextEditOverlay } from "./text-edit-overlay";
+import { usePropertiesStore } from "../properties/stores/properties-store";
+import { useEditor } from "@/hooks/use-editor";
+
+export function PreviewInteractionOverlay() {
+ const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
+
+ const selectedRef =
+ selectedElements.length === 1 ? selectedElements[0] : null;
+ const activeTrack = selectedRef
+ ? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
+ : null;
+ const activeElement =
+ activeTrack?.elements.find(
+ (element) => element.id === selectedRef?.elementId,
+ ) ?? null;
+ const isMaskMode = activeElement
+ ? activeTabPerType[activeElement.type] === "masks"
+ : false;
-export function PreviewInteractionOverlay({
- canvasRef,
- containerRef,
-}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
-}) {
const {
onPointerDown,
onPointerMove,
onPointerUp,
onDoubleClick,
- snapLines,
editingText,
commitTextEdit,
- cancelTextEdit,
- } = usePreviewInteraction({ canvasRef });
+ } = usePreviewInteraction({
+ onSnapLinesChange: setSnapLines,
+ isMaskMode,
+ });
+
+ const handlePointerDown = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerDown({ event })) {
+ return;
+ }
+
+ onPointerDown(event);
+ };
+
+ const handlePointerMove = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerMove({ event })) {
+ return;
+ }
+
+ onPointerMove(event);
+ };
+
+ const handlePointerUp = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerUp({ event })) {
+ return;
+ }
+
+ onPointerUp(event);
+ };
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ const x = e.nativeEvent.offsetX;
+ const y = e.nativeEvent.offsetY;
+ const outsideCanvas =
+ x < viewport.sceneLeft ||
+ x > viewport.sceneLeft + viewport.sceneWidth ||
+ y < viewport.sceneTop ||
+ y > viewport.sceneTop + viewport.sceneHeight;
+ if (outsideCanvas) {
+ e.stopPropagation();
+ }
+ };
return (
<div className="absolute inset-0">
- {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */}
<div
className="absolute inset-0 pointer-events-auto"
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
+ role="application"
+ aria-label="Preview canvas"
+ style={{
+ cursor: viewport.isPanning
+ ? "grabbing"
+ : viewport.canPan
+ ? "default"
+ : undefined,
+ }}
+ onPointerDown={handlePointerDown}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onPointerCancel={handlePointerUp}
onDoubleClick={onDoubleClick}
+ onDragStart={(e) => e.preventDefault()}
+ onContextMenu={handleContextMenu}
/>
{editingText ? (
<TextEditOverlay
- canvasRef={canvasRef}
- containerRef={containerRef}
trackId={editingText.trackId}
elementId={editingText.elementId}
element={editingText.element}
onCommit={commitTextEdit}
- onCancel={cancelTextEdit}
/>
+ ) : isMaskMode ? (
+ <MaskHandles onSnapLinesChange={setSnapLines} />
) : (
- <TransformHandles canvasRef={canvasRef} containerRef={containerRef} />
+ <TransformHandles onSnapLinesChange={setSnapLines} />
)}
- <SnapGuides
- lines={snapLines}
- canvasRef={canvasRef}
- containerRef={containerRef}
- />
+ <SnapGuides lines={snapLines} />
</div>
);
}
diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
new file mode 100644
index 00000000..e38183af
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx
@@ -0,0 +1,576 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ canvasToOverlay,
+ getDisplayScale,
+ positionToOverlay,
+ screenPixelsToLogicalThreshold,
+ screenToCanvas,
+} from "@/lib/preview/preview-coords";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import { PREVIEW_ZOOM } from "@/constants/editor-constants";
+
+const MIDDLE_MOUSE_BUTTON = 1;
+const IS_AT_FIT_EPSILON = 0.001;
+const IS_AT_ACTUAL_SIZE_EPSILON = 0.01;
+
+interface PreviewViewportContextValue {
+ canPan: boolean;
+ isAtFit: boolean;
+ isAtActualSize: boolean;
+ isPanning: boolean;
+ sceneHeight: number;
+ sceneLeft: number;
+ sceneTop: number;
+ sceneWidth: number;
+ zoomPercent: number;
+ canvasToOverlay: ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) => { x: number; y: number };
+ fitToScreen: () => void;
+ getDisplayScale: () => { x: number; y: number };
+ handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean;
+ positionToOverlay: ({
+ positionX,
+ positionY,
+ }: {
+ positionX: number;
+ positionY: number;
+ }) => { x: number; y: number };
+ panByScreenDelta: ({
+ deltaX,
+ deltaY,
+ }: {
+ deltaX: number;
+ deltaY: number;
+ }) => void;
+ resetPan: () => void;
+ scaleZoom: ({ factor }: { factor: number }) => void;
+ screenPixelsToLogicalThreshold: ({
+ screenPixels,
+ }: {
+ screenPixels: number;
+ }) => { x: number; y: number };
+ screenToCanvas: ({
+ clientX,
+ clientY,
+ }: {
+ clientX: number;
+ clientY: number;
+ }) => { x: number; y: number } | null;
+ setActualSize: () => void;
+ setViewportPercent: ({ percent }: { percent: number }) => void;
+ zoomIn: () => void;
+ zoomOut: () => void;
+}
+
+const PreviewViewportContext =
+ createContext<PreviewViewportContextValue | null>(null);
+
+interface PreviewViewportProviderProps {
+ children: React.ReactNode;
+ value: PreviewViewportContextValue;
+}
+
+interface PreviewViewportStateOptions {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportRef: React.RefObject<HTMLDivElement | null>;
+ viewportWidth: number;
+}
+
+interface PanSession {
+ centerX: number;
+ centerY: number;
+ clientX: number;
+ clientY: number;
+ pointerId: number;
+}
+
+function getClampedCenterAxis({
+ axisSize,
+ center,
+ scale,
+ viewportSize,
+}: {
+ axisSize: number;
+ center: number;
+ scale: number;
+ viewportSize: number;
+}): number {
+ if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) {
+ return axisSize / 2;
+ }
+
+ const visibleHalfSpan = viewportSize / (2 * scale);
+ if (visibleHalfSpan >= axisSize / 2) {
+ return axisSize / 2;
+ }
+
+ return clamp({
+ value: center,
+ min: visibleHalfSpan,
+ max: axisSize - visibleHalfSpan,
+ });
+}
+
+function clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX,
+ centerY,
+ scale,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ centerX: number;
+ centerY: number;
+ scale: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): { x: number; y: number } {
+ return {
+ x: getClampedCenterAxis({
+ center: centerX,
+ axisSize: canvasWidth,
+ scale,
+ viewportSize: viewportWidth,
+ }),
+ y: getClampedCenterAxis({
+ center: centerY,
+ axisSize: canvasHeight,
+ scale,
+ viewportSize: viewportHeight,
+ }),
+ };
+}
+
+function getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): number {
+ if (
+ canvasHeight <= 0 ||
+ canvasWidth <= 0 ||
+ viewportHeight <= 0 ||
+ viewportWidth <= 0
+ ) {
+ return 1;
+ }
+
+ return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight);
+}
+
+function getClampedZoom({ zoom }: { zoom: number }): number {
+ return clamp({
+ value: zoom,
+ min: PREVIEW_ZOOM.min,
+ max: PREVIEW_ZOOM.max,
+ });
+}
+
+export function PreviewViewportProvider({
+ children,
+ value,
+}: PreviewViewportProviderProps) {
+ return (
+ <PreviewViewportContext.Provider value={value}>
+ {children}
+ </PreviewViewportContext.Provider>
+ );
+}
+
+export function usePreviewViewportState({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportRef,
+ viewportWidth,
+}: PreviewViewportStateOptions): PreviewViewportContextValue {
+ const [zoom, setZoomState] = useState(1);
+ const [center, setCenter] = useState(() => ({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ }));
+ const [isPanning, setIsPanning] = useState(false);
+ const panSessionRef = useRef<PanSession | null>(null);
+ const centerRef = useRef(center);
+ centerRef.current = center;
+
+ const fitScale = useMemo(
+ () =>
+ getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [canvasHeight, canvasWidth, viewportHeight, viewportWidth],
+ );
+ const viewportScale = fitScale * zoom;
+ const geometry = useMemo(
+ () => ({
+ canvasHeight,
+ canvasWidth,
+ centerX: center.x,
+ centerY: center.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [
+ canvasHeight,
+ canvasWidth,
+ center.x,
+ center.y,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ ],
+ );
+
+ const scaleZoom = useCallback(({ factor }: { factor: number }) => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * factor,
+ }),
+ );
+ }, []);
+
+ const panByScreenDelta = useCallback(
+ ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
+ if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) {
+ return;
+ }
+
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x + deltaX / viewportScale,
+ centerY: previousCenter.y + deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ },
+ [
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ zoom,
+ ],
+ );
+
+ const resetPan = useCallback(() => {
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const fitToScreen = useCallback(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const zoomIn = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const zoomOut = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom / PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const setActualSize = useCallback(() => {
+ const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1;
+ setZoomState(
+ getClampedZoom({
+ zoom: actualSizeZoom,
+ }),
+ );
+ }, [fitScale]);
+
+ const setViewportPercent = useCallback(
+ ({ percent }: { percent: number }) => {
+ const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1;
+ setZoomState(getClampedZoom({ zoom: targetZoom }));
+ },
+ [fitScale],
+ );
+
+ const getViewportRect = useCallback(
+ () => viewportRef.current?.getBoundingClientRect() ?? null,
+ [viewportRef],
+ );
+
+ const screenToCanvasWithViewport = useCallback(
+ ({ clientX, clientY }: { clientX: number; clientY: number }) => {
+ const viewportRect = getViewportRect();
+ if (!viewportRect) return null;
+
+ return screenToCanvas({
+ clientX,
+ clientY,
+ geometry,
+ viewportRect,
+ });
+ },
+ [getViewportRect, geometry],
+ );
+
+ const canvasToOverlayWithViewport = useCallback(
+ ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) =>
+ canvasToOverlay({
+ canvasX,
+ canvasY,
+ geometry,
+ }),
+ [geometry],
+ );
+
+ const positionToOverlayWithViewport = useCallback(
+ ({ positionX, positionY }: { positionX: number; positionY: number }) =>
+ positionToOverlay({
+ geometry,
+ positionX,
+ positionY,
+ }),
+ [geometry],
+ );
+
+ const getDisplayScaleWithViewport = useCallback(
+ () => getDisplayScale({ geometry }),
+ [geometry],
+ );
+
+ const getLogicalThresholdForScreenPixels = useCallback(
+ ({ screenPixels }: { screenPixels: number }) =>
+ screenPixelsToLogicalThreshold({
+ geometry,
+ screenPixels,
+ }),
+ [geometry],
+ );
+
+ const handlePanPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ panSessionRef.current = {
+ centerX: centerRef.current.x,
+ centerY: centerRef.current.y,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ pointerId: event.pointerId,
+ };
+ setIsPanning(true);
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ return true;
+ },
+ [zoom],
+ );
+
+ const handlePanPointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const deltaX = event.clientX - panSession.clientX;
+ const deltaY = event.clientY - panSession.clientY;
+ const nextCenter = clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: panSession.centerX - deltaX / viewportScale,
+ centerY: panSession.centerY - deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ });
+
+ setCenter(nextCenter);
+ return true;
+ },
+ [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth],
+ );
+
+ const handlePanPointerUp = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ if (
+ (event.currentTarget as HTMLElement).hasPointerCapture(
+ panSession.pointerId,
+ )
+ ) {
+ (event.currentTarget as HTMLElement).releasePointerCapture(
+ panSession.pointerId,
+ );
+ }
+
+ panSessionRef.current = null;
+ setIsPanning(false);
+ return true;
+ },
+ [],
+ );
+
+ useEffect(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ panSessionRef.current = null;
+ setIsPanning(false);
+ }, [canvasHeight, canvasWidth]);
+
+ useEffect(() => {
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x,
+ centerY: previousCenter.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]);
+
+ const sceneWidth = canvasWidth * viewportScale;
+ const sceneHeight = canvasHeight * viewportScale;
+ const sceneLeft = viewportWidth / 2 - center.x * viewportScale;
+ const sceneTop = viewportHeight / 2 - center.y * viewportScale;
+ const canPan = zoom > 1;
+ const zoomPercent = Math.round(viewportScale * 100);
+
+ return useMemo(
+ () => ({
+ canPan,
+ isAtActualSize: isNearlyEqual({
+ leftValue: viewportScale,
+ rightValue: 1,
+ epsilon: IS_AT_ACTUAL_SIZE_EPSILON,
+ }),
+ isAtFit: isNearlyEqual({
+ leftValue: zoom,
+ rightValue: 1,
+ epsilon: IS_AT_FIT_EPSILON,
+ }),
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlay: canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScale: getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlay: positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels,
+ screenToCanvas: screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ }),
+ [
+ canPan,
+ viewportScale,
+ zoom,
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ getLogicalThresholdForScreenPixels,
+ screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ ],
+ );
+}
+
+export function usePreviewViewport() {
+ const context = useContext(PreviewViewportContext);
+ if (!context) {
+ throw new Error(
+ "usePreviewViewport must be used within PreviewViewportProvider",
+ );
+ }
+
+ return context;
+}
diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
index 4aa714a1..744304ea 100644
--- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx
+++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
@@ -1,43 +1,25 @@
"use client";
-import { useEditor } from "@/hooks/use-editor";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import type { SnapLine } from "@/lib/preview/preview-snap";
-import { positionToOverlay } from "@/lib/preview/preview-coords";
-export function SnapGuides({
- lines,
- canvasRef,
- containerRef,
-}: {
- lines: SnapLine[];
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
-}) {
- const editor = useEditor();
- const canvasSize = editor.project.getActive().settings.canvasSize;
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
+export function SnapGuides({ lines }: { lines: SnapLine[] }) {
+ const viewport = usePreviewViewport();
- if (!canvasRect || !containerRect || lines.length === 0) {
+ if (lines.length === 0) {
return null;
}
const toOverlayX = (logicalX: number) =>
- positionToOverlay({
+ viewport.positionToOverlay({
positionX: logicalX,
positionY: 0,
- canvasRect,
- containerRect,
- canvasSize,
}).x;
const toOverlayY = (logicalY: number) =>
- positionToOverlay({
+ viewport.positionToOverlay({
positionX: 0,
positionY: logicalY,
- canvasRect,
- containerRect,
- canvasSize,
}).y;
return (
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
index 26649b26..d3c61a24 100644
--- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
@@ -1,38 +1,89 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useEditor } from "@/hooks/use-editor";
-import type { TextElement } from "@/types/timeline";
+import type { TextElement } from "@/lib/timeline";
import {
- positionToOverlay,
- getDisplayScale,
-} from "@/lib/preview/preview-coords";
-import {
- DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ getMetricAscent,
+ getMetricDescent,
+ setCanvasLetterSpacing,
+} from "@/lib/text/layout";
+
+let cachedCanvas: HTMLCanvasElement | null = null;
+
+function getMeasurementContext(): CanvasRenderingContext2D | null {
+ if (!cachedCanvas) cachedCanvas = document.createElement("canvas");
+ return cachedCanvas.getContext("2d");
+}
+
+/**
+ * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than
+ * actualBoundingBox to model where the browser places the baseline within the
+ * line box, then measures actual glyph bounds to find the visual center.
+ */
+function measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx,
+ lineHeightPx,
+ displayFontSize,
+}: {
+ lines: string[];
+ fontString: string;
+ letterSpacingPx: number;
+ lineHeightPx: number;
+ displayFontSize: number;
+}): number {
+ const ctx = getMeasurementContext();
+ if (!ctx) return 0;
+
+ ctx.font = fontString;
+ ctx.textBaseline = "alphabetic";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx });
+
+ const probe = ctx.measureText("M");
+ const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8;
+ const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2;
+ const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2;
-const TEXT_BACKGROUND_PADDING = "4px 8px";
-const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06;
+ let visualTop = Number.POSITIVE_INFINITY;
+ let visualBottom = Number.NEGATIVE_INFINITY;
+
+ for (let i = 0; i < lines.length; i++) {
+ const metrics = ctx.measureText(lines[i] || " ");
+ const baseline = i * lineHeightPx + halfLeading + fontAscent;
+ visualTop = Math.min(
+ visualTop,
+ baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ visualBottom = Math.max(
+ visualBottom,
+ baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ }
+
+ const cssBlockHeight = lines.length * lineHeightPx;
+ return (visualTop + visualBottom) / 2 - cssBlockHeight / 2;
+}
export function TextEditOverlay({
- canvasRef,
- containerRef,
trackId,
elementId,
element,
onCommit,
- onCancel,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
trackId: string;
elementId: string;
element: TextElement;
onCommit: () => void;
- onCancel: () => void;
}) {
const editor = useEditor();
+ const viewport = usePreviewViewport();
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -60,59 +111,67 @@ export function TextEditOverlay({
const { key } = event;
if (key === "Escape") {
event.preventDefault();
- onCancel();
+ onCommit();
return;
}
},
- [onCancel],
+ [onCommit],
);
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
const canvasSize = editor.project.getActive().settings.canvasSize;
- if (!canvasRect || !containerRect || !canvasSize) return null;
+ if (!canvasSize) return null;
- const { x: posX, y: posY } = positionToOverlay({
+ const { x: posX, y: posY } = viewport.positionToOverlay({
positionX: element.transform.position.x,
positionY: element.transform.position.y,
- canvasRect,
- containerRect,
- canvasSize,
});
- const { x: displayScaleX } = getDisplayScale({
- canvasRect,
- canvasSize,
- });
+ const { x: displayScaleX } = viewport.getDisplayScale();
const displayFontSize =
element.fontSize *
(canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
displayScaleX;
- const verticalAlignmentOffset =
- displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM;
-
- const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
+ const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const letterSpacing = element.letterSpacing ?? 0;
+ const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX;
+ const lineHeightPx = displayFontSize * lineHeight;
+ const lines = (element.content || "").split("\n");
+ const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`;
+
+ const cssVisualCenterOffset = measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx: displayLetterSpacing,
+ lineHeightPx,
+ displayFontSize,
+ });
+
+ const bg = element.background;
const shouldShowBackground =
- element.background.enabled &&
- element.background.color &&
- element.background.color !== "transparent";
- const backgroundColor = shouldShowBackground
- ? element.background.color
- : "transparent";
+ bg.enabled && bg.color && bg.color !== "transparent";
+ const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
+ const displayPaddingX = shouldShowBackground
+ ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
+ const displayPaddingY = shouldShowBackground
+ ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
return (
<div
className="absolute"
style={{
left: posX,
- top: posY - verticalAlignmentOffset,
- transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`,
+ top: posY - cssVisualCenterOffset,
+ transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`,
transformOrigin: "center center",
}}
>
@@ -131,13 +190,15 @@ export function TextEditOverlay({
fontWeight,
fontStyle,
textAlign: element.textAlign,
- letterSpacing: `${letterSpacing}px`,
+ letterSpacing: `${displayLetterSpacing}px`,
lineHeight,
color: element.color,
- backgroundColor,
- minHeight: displayFontSize * lineHeight,
+ backgroundColor: shouldShowBackground ? bg.color : "transparent",
+ minHeight: lineHeightPx,
textDecoration: element.textDecoration ?? "none",
- padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0,
+ padding: shouldShowBackground
+ ? `${displayPaddingY}px ${displayPaddingX}px`
+ : 0,
minWidth: 1,
}}
onInput={handleInput}
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx
index 7f5cd9bc..56cb784a 100644
--- a/apps/web/src/components/editor/panels/preview/toolbar.tsx
+++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useState, useEffect } from "react";
import { useEditor } from "@/hooks/use-editor";
import { formatTimeCode } from "@/lib/time";
import { invokeAction } from "@/lib/actions";
@@ -7,74 +8,144 @@ import { EditableTimecode } from "@/components/editable-timecode";
import { Button } from "@/components/ui/button";
import {
FullScreenIcon,
+ GridTableIcon,
PauseIcon,
PlayIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { OcSocialIcon } from "@opencut/ui/icons";
+import { getGuideById } from "@/lib/guides";
import { Separator } from "@/components/ui/separator";
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+} from "@/components/ui/select";
+import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
+import { usePreviewViewport } from "./preview-viewport";
+import { GridPopover } from "./guide-popover";
+import { usePreviewStore } from "@/stores/preview-store";
export function PreviewToolbar({
- isFullscreen,
onToggleFullscreen,
}: {
- isFullscreen: boolean;
onToggleFullscreen: () => void;
}) {
- const editor = useEditor();
- const isPlaying = editor.playback.getIsPlaying();
- const currentTime = editor.playback.getCurrentTime();
- const totalDuration = editor.timeline.getTotalDuration();
- const fps = editor.project.getActive().settings.fps;
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const activeGuideDefinition = getGuideById(activeGuide);
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
- <div className="flex items-center">
- <EditableTimecode
- time={currentTime}
- duration={totalDuration}
- format="HH:MM:SS:FF"
- fps={fps}
- onTimeChange={({ time }) => editor.playback.seek({ time })}
- className="text-center"
- />
- <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
- <span className="text-muted-foreground font-mono text-xs">
- {formatTimeCode({
- timeInSeconds: totalDuration,
- format: "HH:MM:SS:FF",
- fps,
- })}
- </span>
- </div>
-
- <Button
- variant="text"
- size="icon"
- onClick={() => invokeAction("toggle-play")}
- >
- <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
- </Button>
-
+ <TimecodeDisplay />
+ <PlayPauseButton />
<div className="justify-self-end flex items-center gap-2.5">
- <Button
- variant="secondary"
- size="sm"
- className="[&_svg]:size-auto px-1 h-7"
- onClick={onToggleFullscreen}
- title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
- >
- <OcSocialIcon size={20} />
- </Button>
+ <ZoomSelect />
<Separator orientation="vertical" className="h-4" />
- <Button
- variant="text"
- onClick={onToggleFullscreen}
- title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
- >
+ <GridPopover>
+ <Button
+ variant={activeGuideDefinition ? "secondary" : "text"}
+ size="icon"
+ >
+ {activeGuideDefinition ? (
+ activeGuideDefinition.renderTriggerIcon()
+ ) : (
+ <HugeiconsIcon icon={GridTableIcon} />
+ )}
+ </Button>
+ </GridPopover>
+ <Button variant="text" onClick={onToggleFullscreen}>
<HugeiconsIcon icon={FullScreenIcon} />
</Button>
</div>
</div>
);
}
+
+function TimecodeDisplay() {
+ const editor = useEditor();
+ const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
+ const fps = useEditor((e) => e.project.getActive().settings.fps);
+ const [currentTime, setCurrentTime] = useState(() =>
+ editor.playback.getCurrentTime(),
+ );
+
+ useEffect(() => {
+ const handler = (e: Event) =>
+ setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
+ window.addEventListener("playback-update", handler);
+ window.addEventListener("playback-seek", handler);
+ return () => {
+ window.removeEventListener("playback-update", handler);
+ window.removeEventListener("playback-seek", handler);
+ };
+ }, []);
+
+ return (
+ <div className="flex items-center">
+ <EditableTimecode
+ time={currentTime}
+ duration={totalDuration}
+ format="HH:MM:SS:FF"
+ fps={fps}
+ onTimeChange={({ time }) => editor.playback.seek({ time })}
+ className="text-center"
+ />
+ <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
+ <span className="text-muted-foreground font-mono text-xs">
+ {formatTimeCode({
+ timeInSeconds: totalDuration,
+ format: "HH:MM:SS:FF",
+ fps,
+ })}
+ </span>
+ </div>
+ );
+}
+
+function ZoomSelect() {
+ const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
+ usePreviewViewport();
+
+ const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
+
+ const onValueChange = (value: string) => {
+ if (value === "fit") {
+ fitToScreen();
+ } else {
+ setViewportPercent({ percent: Number(value) });
+ }
+ };
+
+ return (
+ <Select
+ value={isAtFit ? "fit" : String(zoomPercent)}
+ onValueChange={onValueChange}
+ >
+ <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
+ <SelectContent>
+ <SelectItem value="fit">Fit</SelectItem>
+ <SelectSeparator />
+ {PREVIEW_ZOOM_PRESETS.map((preset) => (
+ <SelectItem key={preset} value={String(preset)}>
+ {preset}%
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+}
+
+function PlayPauseButton() {
+ const isPlaying = useEditor((e) => e.playback.getIsPlaying());
+
+ return (
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => invokeAction("toggle-play")}
+ >
+ <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
index 2fcf8096..88332625 100644
--- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx
+++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
@@ -1,112 +1,55 @@
"use client";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
import { useTransformHandles } from "@/hooks/use-transform-handles";
-import { useEditor } from "@/hooks/use-editor";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import { SnapGuides } from "./snap-guides";
-import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ getCornerPosition,
+ getEdgeHandlePosition,
+ getRotationHandlePosition,
+ type Corner,
+ type Edge,
+} from "@/lib/preview/element-bounds";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
+import {
+ BoundingBoxOutline,
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ getResizeCursor,
+} from "./handle-primitives";
import { Rotate01Icon } from "@hugeicons/core-free-icons";
-const HANDLE_SIZE = 10;
-const ROTATION_HANDLE_OFFSET = 24;
-const ROTATION_HANDLE_RADIUS = 10;
-const CORNER_HIT_AREA_SIZE = 18;
-
-type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const CORNERS: Corner[] = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
];
-
-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 getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
- x: number;
- y: number;
-} {
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
- return {
- x: bounds.cx - localY * sin,
- y: bounds.cy + localY * cos,
- };
-}
-
-export function getOverlayContext({
- canvasRef,
- containerRef,
-}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
-}): {
- canvasRect: DOMRect;
- containerRect: DOMRect;
-} | null {
- const canvasRect = canvasRef.current?.getBoundingClientRect();
- const containerRect = containerRef.current?.getBoundingClientRect();
- if (!canvasRect || !containerRect) return null;
- return { canvasRect, containerRect };
-}
+const EDGES: Edge[] = ["right", "left", "bottom"];
export function TransformHandles({
- canvasRef,
- containerRef,
+ onSnapLinesChange,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- containerRef: React.RefObject<HTMLDivElement | null>;
+ onSnapLinesChange?: OnSnapLinesChange;
}) {
+ const viewport = usePreviewViewport();
const {
selectedWithBounds,
hasVisualSelection,
- snapLines,
handleCornerPointerDown,
+ handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
- } = useTransformHandles({ canvasRef });
-
- const editor = useEditor();
- const canvasSize = editor.project.getActive().settings.canvasSize;
+ } = useTransformHandles({ onSnapLinesChange });
if (!hasVisualSelection || !selectedWithBounds) return null;
- const overlayContext = getOverlayContext({ canvasRef, containerRef });
- if (!overlayContext) return null;
-
const { bounds, element } = selectedWithBounds;
if (!isVisualElement(element)) return null;
- const { canvasRect, containerRect } = overlayContext;
- const displayScale = getDisplayScale({ canvasRect, canvasSize });
+ const displayScale = viewport.getDisplayScale();
const toOverlay = ({
canvasX,
@@ -115,31 +58,30 @@ export function TransformHandles({
canvasX: number;
canvasY: number;
}) =>
- canvasToOverlay({
+ viewport.canvasToOverlay({
canvasX,
canvasY,
- canvasRect,
- containerRect,
- canvasSize,
});
const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
- const outlineWidth = bounds.width * displayScale.x;
- const outlineHeight = bounds.height * displayScale.y;
+ const outlineWidth = Math.abs(bounds.width) * displayScale.x;
+ const outlineHeight = Math.abs(bounds.height) * displayScale.y;
- const rotationHandle = getRotationHandlePosition({ bounds });
+ const rotationHandleCanvas = getRotationHandlePosition({ bounds });
const rotationHandleScreen = toOverlay({
- canvasX: rotationHandle.x,
- canvasY: rotationHandle.y,
+ canvasX: rotationHandleCanvas.x,
+ canvasY: rotationHandleCanvas.y,
});
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
return (
- <div className="pointer-events-none absolute inset-0" aria-hidden>
- <SnapGuides
- lines={snapLines}
- canvasRef={canvasRef}
- containerRef={containerRef}
- />
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ aria-hidden
+ >
<BoundingBoxOutline
center={center}
outlineWidth={outlineWidth}
@@ -152,132 +94,47 @@ export function TransformHandles({
canvasX: cornerPosition.x,
canvasY: cornerPosition.y,
});
+ const angleDeg =
+ Math.atan2(screen.y - center.y, screen.x - center.x) *
+ (180 / Math.PI);
return (
<CornerHandle
key={corner}
- corner={corner}
+ cursor={getResizeCursor({ angleDeg })}
screen={screen}
onPointerDown={(event) =>
handleCornerPointerDown({ event, corner })
}
- onPointerMove={(event) => handlePointerMove({ event })}
- onPointerUp={(event) => handlePointerUp({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ {EDGES.map((edge) => {
+ const edgePosition = getEdgeHandlePosition({ bounds, edge });
+ const screen = toOverlay({
+ canvasX: edgePosition.x,
+ canvasY: edgePosition.y,
+ });
+ return (
+ <EdgeHandle
+ key={edge}
+ edge={edge}
+ screen={screen}
+ rotation={bounds.rotation}
+ onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
/>
);
})}
- <RotationHandle
+ <IconHandle
+ icon={Rotate01Icon}
screen={rotationHandleScreen}
onPointerDown={(event) => handleRotationPointerDown({ event })}
- onPointerMove={(event) => handlePointerMove({ event })}
- onPointerUp={(event) => handlePointerUp({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
/>
</div>
);
}
-
-function BoundingBoxOutline({
- center,
- outlineWidth,
- outlineHeight,
- rotation,
-}: {
- center: { x: number; y: number };
- outlineWidth: number;
- outlineHeight: number;
- rotation: number;
-}) {
- return (
- <div
- className="pointer-events-none absolute"
- style={{
- left: center.x - outlineWidth / 2,
- top: center.y - outlineHeight / 2,
- width: outlineWidth,
- height: outlineHeight,
- transform: `rotate(${rotation}deg)`,
- transformOrigin: "center center",
- border: "1px dashed white",
- boxSizing: "border-box",
- opacity: 0.75,
- }}
- />
- );
-}
-
-function CornerHandle({
- corner,
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- corner: Corner;
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- const isNesw = corner === "top-right" || corner === "bottom-left";
-
- return (
- <button
- type="button"
- className={cn(
- "absolute flex items-center justify-center outline-none",
- isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize",
- )}
- style={{
- left: screen.x - CORNER_HIT_AREA_SIZE / 2,
- top: screen.y - CORNER_HIT_AREA_SIZE / 2,
- width: CORNER_HIT_AREA_SIZE,
- height: CORNER_HIT_AREA_SIZE,
- pointerEvents: "auto",
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
- onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
- >
- <div
- className="rounded-sm bg-white"
- style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
- />
- </button>
- );
-}
-
-function RotationHandle({
- screen,
- onPointerDown,
- onPointerMove,
- onPointerUp,
-}: {
- screen: { x: number; y: number };
- onPointerDown: (event: React.PointerEvent) => void;
- onPointerMove: (event: React.PointerEvent) => void;
- onPointerUp: (event: React.PointerEvent) => void;
-}) {
- return (
- <button
- type="button"
- className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none"
- style={{
- left: screen.x - ROTATION_HANDLE_RADIUS,
- top: screen.y - ROTATION_HANDLE_RADIUS,
- width: ROTATION_HANDLE_RADIUS * 2,
- height: ROTATION_HANDLE_RADIUS * 2,
- pointerEvents: "auto",
- }}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerLeave={onPointerUp}
- onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
- onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
- >
- <HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} />
- </button>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
deleted file mode 100644
index 2ad46497..00000000
--- a/apps/web/src/components/editor/panels/properties/audio-properties.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export function AudioProperties() {
- return <div className="space-y-4 p-5">Audio properties</div>;
-}
diff --git a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx b/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx
deleted file mode 100644
index a1324bdf..00000000
--- a/apps/web/src/components/editor/panels/properties/clip-effects-properties.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import type { Effect } from "@/types/effects";
-import type { VisualElement } from "@/types/timeline";
-import { getEffect } from "@/lib/effects/registry";
-import { useEditor } from "@/hooks/use-editor";
-import { usePropertiesStore } from "@/stores/properties-store";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
- SectionFields,
-} from "./section";
-import { EffectParamField } from "./effect-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowLeft01Icon,
- Delete02Icon,
- ViewIcon,
- ViewOffSlashIcon,
-} from "@hugeicons/core-free-icons";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { cn } from "@/utils/ui";
-export function ClipEffectsProperties({
- element,
- trackId,
-}: {
- element: VisualElement;
- trackId: string;
-}) {
- const closeClipEffects = usePropertiesStore(
- (state) => state.closeClipEffects,
- );
- const editor = useEditor();
- const effects = element.effects ?? [];
-
- useEffect(() => {
- if (effects.length === 0) closeClipEffects();
- }, [effects.length, closeClipEffects]);
-
- const [dragIndex, setDragIndex] = useState<number | null>(null);
- const [dropIndex, setDropIndex] = useState<number | null>(null);
-
- const handleDragStart = ({ index }: { index: number }) => {
- setDragIndex(index);
- };
-
- const handleDragOver = ({ event, index }: { event: React.DragEvent; index: number }) => {
- event.preventDefault();
- if (index !== dropIndex) setDropIndex(index);
- };
-
- const handleDrop = ({ toIndex }: { toIndex: number }) => {
- if (dragIndex !== null && dragIndex !== toIndex) {
- editor.timeline.reorderClipEffects({
- trackId,
- elementId: element.id,
- fromIndex: dragIndex,
- toIndex,
- });
- }
- setDragIndex(null);
- setDropIndex(null);
- };
-
- const handleDragEnd = () => {
- setDragIndex(null);
- setDropIndex(null);
- };
-
- return (
- <div className="flex h-full flex-col">
- <div className="flex h-11 shrink-0 items-center gap-2 border-b px-1.5">
- <Button
- variant="ghost"
- size="icon"
- onClick={closeClipEffects}
- aria-label="Back to properties"
- >
- <HugeiconsIcon icon={ArrowLeft01Icon} />
- </Button>
- <span className="text-sm font-medium">Effects</span>
- </div>
- <ScrollArea className="flex-1 scrollbar-hidden">
- {effects.map((effect, index) => (
- // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop list reorder
- <div
- key={effect.id}
- draggable
- onDragStart={() => handleDragStart({ index })}
- onDragOver={(event) => handleDragOver({ event, index })}
- onDrop={() => handleDrop({ toIndex: index })}
- onDragEnd={handleDragEnd}
- className={cn(
- "group",
- dragIndex === index && "opacity-40",
- dropIndex === index &&
- dragIndex !== null &&
- dragIndex !== index &&
- (index < dragIndex
- ? "border-t-2 border-primary"
- : "border-b-2 border-primary"),
- )}
- >
- <ClipEffectSection
- effect={effect}
- element={element}
- trackId={trackId}
- />
- </div>
- ))}
- </ScrollArea>
- </div>
- );
-}
-
-function ClipEffectSection({
- effect,
- element,
- trackId,
-}: {
- effect: Effect;
- element: VisualElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = getEffect({ effectType: effect.type });
-
- const previewParam = ({ key }: { key: string }) => (value: number | string | boolean) => {
- const updatedEffects = (element.effects ?? []).map((existing) =>
- existing.id !== effect.id
- ? existing
- : { ...existing, params: { ...existing.params, [key]: value } },
- );
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { effects: updatedEffects },
- },
- ],
- });
- };
-
- const commitParam = () => editor.timeline.commitPreview();
-
- const toggleEffect = () =>
- editor.timeline.toggleClipEffect({
- trackId,
- elementId: element.id,
- effectId: effect.id,
- });
-
- const removeEffect = () =>
- editor.timeline.removeClipEffect({
- trackId,
- elementId: element.id,
- effectId: effect.id,
- });
-
- return (
- <Section sectionKey={`clip-effect:${effect.id}`} showTopBorder={false}>
- <SectionHeader
- className="cursor-move"
- trailing={
- <div className="flex items-center gap-1">
- <Button
- variant={effect.enabled ? "secondary" : "ghost"}
- size="icon"
- aria-label={`Toggle ${definition.name}`}
- onClick={toggleEffect}
- >
- <HugeiconsIcon
- icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
- />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- aria-label={`Remove ${definition.name}`}
- onClick={removeEffect}
- >
- <HugeiconsIcon icon={Delete02Icon} />
- </Button>
- </div>
- }
- >
- <SectionTitle
- className={cn(!effect.enabled && "text-muted-foreground")}
- >
- {definition.name}
- </SectionTitle>
- </SectionHeader>
- {effect.enabled && (
- <SectionContent>
- <SectionFields>
- {definition.params.map((param) => (
- <EffectParamField
- key={param.key}
- param={param}
- value={effect.params[param.key] ?? param.default}
- onPreview={previewParam({ key: param.key })}
- onCommit={commitParam}
- />
- ))}
- </SectionFields>
- </SectionContent>
- )}
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx
index b5b8685f..b5b8685f 100644
--- a/apps/web/src/components/editor/panels/properties/keyframe-toggle.tsx
+++ b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx
diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
new file mode 100644
index 00000000..e69c6918
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
+import { formatNumberForDisplay, snapToStep } from "@/utils/math";
+import { SectionField } from "@/components/section";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import { ColorPicker } from "@/components/ui/color-picker";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { KeyframeToggle } from "./keyframe-toggle";
+
+export function PropertyParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+ keyframe,
+}: {
+ param: ParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ keyframe?: {
+ isActive: boolean;
+ isDisabled: boolean;
+ onToggle: () => void;
+ };
+}) {
+ return (
+ <SectionField
+ label={param.label}
+ beforeLabel={
+ keyframe ? (
+ <KeyframeToggle
+ isActive={keyframe.isActive}
+ isDisabled={keyframe.isDisabled}
+ title={`Toggle ${param.label.toLowerCase()} keyframe`}
+ onToggle={keyframe.onToggle}
+ />
+ ) : undefined
+ }
+ >
+ <ParamInput
+ param={param}
+ value={value}
+ onPreview={onPreview}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ );
+}
+
+function ParamInput({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: ParamDefinition;
+ value: number | string | boolean;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+}) {
+ if (param.type === "number") {
+ return (
+ <NumberParamField
+ param={param}
+ value={typeof value === "number" ? value : Number(value)}
+ onPreview={onPreview}
+ onCommit={onCommit}
+ />
+ );
+ }
+
+ if (param.type === "boolean") {
+ return (
+ <Switch
+ checked={Boolean(value)}
+ onCheckedChange={(checked) => {
+ onPreview(checked);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ if (param.type === "select") {
+ return (
+ <Select
+ value={String(value)}
+ onValueChange={(selected) => {
+ onPreview(selected);
+ onCommit();
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {param.options.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+ }
+
+ if (param.type === "color") {
+ return (
+ <ColorPicker
+ value={String(value).replace(/^#/, "").toUpperCase()}
+ onChange={(color) => onPreview(`#${color}`)}
+ onChangeEnd={(color) => {
+ onPreview(`#${color}`);
+ onCommit();
+ }}
+ />
+ );
+ }
+
+ return null;
+}
+
+function NumberParamField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+}: {
+ param: NumberParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+}) {
+ const { min, max, step, displayMultiplier = 1 } = param;
+ const displayValue = value * displayMultiplier;
+ const clampDisplayValue = (nextDisplayValue: number) =>
+ Math.max(
+ min,
+ max !== undefined ? Math.min(max, nextDisplayValue) : nextDisplayValue,
+ );
+
+ const previewFromDisplay = (displayVal: number) => {
+ const clamped = clampDisplayValue(
+ snapToStep({ value: displayVal, step }),
+ );
+ onPreview(clamped / displayMultiplier);
+ };
+
+ const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
+
+ const draft = usePropertyDraft({
+ displayValue: formatNumberForDisplay({
+ value: displayValue,
+ maxFractionDigits,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clampDisplayValue(snapToStep({ value: parsed, step }));
+ },
+ onPreview: previewFromDisplay,
+ onCommit,
+ });
+
+ const handleReset = () => {
+ onPreview(param.default);
+ onCommit();
+ };
+
+ return (
+ <NumberField
+ icon={param.shortLabel}
+ value={draft.displayValue}
+ dragSensitivity="slow"
+ isDefault={value === param.default}
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ onScrub={previewFromDisplay}
+ onScrubEnd={onCommit}
+ onReset={handleReset}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx b/apps/web/src/components/editor/panels/properties/effect-param-field.tsx
deleted file mode 100644
index 408d430a..00000000
--- a/apps/web/src/components/editor/panels/properties/effect-param-field.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-"use client";
-
-import type { EffectParamDefinition, NumberEffectParamDefinition } from "@/types/effects";
-import { clamp } from "@/utils/math";
-import { SectionField } from "./section";
-import { Slider } from "@/components/ui/slider";
-import { NumberField } from "@/components/ui/number-field";
-import { Switch } from "@/components/ui/switch";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { usePropertyDraft } from "./hooks/use-property-draft";
-
-export function EffectParamField({
- param,
- value,
- onPreview,
- onCommit,
-}: {
- param: EffectParamDefinition;
- value: number | string | boolean;
- onPreview: (value: number | string | boolean) => void;
- onCommit: () => void;
-}) {
- return (
- <SectionField label={param.label}>
- <EffectParamInput param={param} value={value} onPreview={onPreview} onCommit={onCommit} />
- </SectionField>
- );
-}
-
-function EffectParamInput({
- param,
- value,
- onPreview,
- onCommit,
-}: {
- param: EffectParamDefinition;
- value: number | string | boolean;
- onPreview: (value: number | string | boolean) => void;
- onCommit: () => void;
-}) {
- if (param.type === "number") {
- return (
- <NumberParamField
- param={param}
- value={typeof value === "number" ? value : Number(value)}
- onPreview={onPreview}
- onCommit={onCommit}
- />
- );
- }
-
- if (param.type === "boolean") {
- return (
- <Switch
- checked={Boolean(value)}
- onCheckedChange={(checked) => {
- onPreview(checked);
- onCommit();
- }}
- />
- );
- }
-
- if (param.type === "select") {
- return (
- <Select
- value={String(value)}
- onValueChange={(selected) => {
- onPreview(selected);
- onCommit();
- }}
- >
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {param.options.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- );
- }
-
- if (param.type === "color") {
- return (
- <input
- type="color"
- className="h-8 w-full cursor-pointer rounded border"
- value={String(value)}
- onChange={(event) => onPreview(event.target.value)}
- onBlur={onCommit}
- />
- );
- }
-
- return null;
-}
-
-function NumberParamField({
- param,
- value,
- onPreview,
- onCommit,
-}: {
- param: NumberEffectParamDefinition;
- value: number;
- onPreview: (value: number) => void;
- onCommit: () => void;
-}) {
- const { min, max, step } = param;
-
- const draft = usePropertyDraft({
- displayValue: String(value),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min, max });
- },
- onPreview,
- onCommit,
- });
-
- return (
- <div className="flex items-center gap-3">
- <Slider
- className="flex-1"
- min={min}
- max={max}
- step={step}
- value={[value]}
- onValueChange={([newValue]) => onPreview(newValue)}
- onValueCommit={onCommit}
- />
- <NumberField
- className="w-16 shrink-0"
- value={draft.displayValue}
- onFocus={draft.onFocus}
- onChange={draft.onChange}
- onBlur={draft.onBlur}
- />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/effect-properties.tsx b/apps/web/src/components/editor/panels/properties/effect-properties.tsx
deleted file mode 100644
index 75fc30a1..00000000
--- a/apps/web/src/components/editor/panels/properties/effect-properties.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"use client";
-
-import type { EffectElement } from "@/types/timeline";
-import { getEffect } from "@/lib/effects/registry";
-import { useEditor } from "@/hooks/use-editor";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionFields,
- SectionTitle,
-} from "./section";
-import { EffectParamField } from "./effect-param-field";
-
-export function EffectProperties({
- element,
- trackId,
-}: {
- element: EffectElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = getEffect({ effectType: element.effectType });
-
- const previewParam =
- ({ key }: { key: string }) =>
- (value: number | string | boolean) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { params: { ...element.params, [key]: value } },
- },
- ],
- });
-
- return (
- <Section showTopBorder={false}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {definition.params.map((param) => (
- <EffectParamField
- key={param.key}
- param={param}
- value={element.params[param.key] ?? param.default}
- onPreview={previewParam({ key: param.key })}
- onCommit={() => editor.timeline.commitPreview()}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
index dbd19e4c..957ee1c9 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
@@ -4,8 +4,8 @@ import {
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
export function useKeyframedColorProperty({
trackId,
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
index 11aa049c..0f0168f8 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
@@ -4,8 +4,9 @@ import {
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedNumberProperty({
@@ -18,6 +19,7 @@ export function useKeyframedNumberProperty({
displayValue,
parse,
valueAtPlayhead,
+ step,
buildBaseUpdates,
}: {
trackId: string;
@@ -29,9 +31,12 @@ export function useKeyframedNumberProperty({
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
+ step?: number;
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
}) {
const editor = useEditor();
+ const snapValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
@@ -43,6 +48,7 @@ export function useKeyframedNumberProperty({
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
@@ -54,7 +60,7 @@ export function useKeyframedNumberProperty({
animations,
propertyPath,
time: localTime,
- value,
+ value: nextValue,
}),
},
},
@@ -68,7 +74,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
- updates: buildBaseUpdates({ value }),
+ updates: buildBaseUpdates({ value: nextValue }),
},
],
});
@@ -76,7 +82,10 @@ export function useKeyframedNumberProperty({
const propertyDraft = usePropertyDraft({
displayValue,
- parse,
+ parse: (input) => {
+ const parsedValue = parse(input);
+ return parsedValue === null ? null : snapValue(parsedValue);
+ },
onPreview: (value) => previewValue({ value }),
onCommit: () => editor.timeline.commitPreview(),
});
@@ -107,13 +116,14 @@ export function useKeyframedNumberProperty({
elementId,
propertyPath,
time: localTime,
- value: valueAtPlayhead,
+ value: snapValue(valueAtPlayhead),
},
],
});
};
const commitValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
editor.timeline.upsertKeyframes({
keyframes: [
@@ -122,7 +132,7 @@ export function useKeyframedNumberProperty({
elementId,
propertyPath,
time: localTime,
- value,
+ value: nextValue,
},
],
});
@@ -134,7 +144,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
- updates: buildBaseUpdates({ value }),
+ updates: buildBaseUpdates({ value: nextValue }),
},
],
});
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
new file mode 100644
index 00000000..335e3fb6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
@@ -0,0 +1,148 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import {
+ buildGraphicParamPath,
+ getKeyframeAtTime,
+ getParamDefaultInterpolation,
+ getParamValueKind,
+ hasKeyframesForPath,
+ upsertPathKeyframe,
+} from "@/lib/animation";
+import type {
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { ParamDefinition } from "@/lib/params";
+import type { TimelineElement } from "@/lib/timeline";
+
+export interface KeyframedParamPropertyResult {
+ hasAnimatedKeyframes: boolean;
+ isKeyframedAtTime: boolean;
+ keyframeIdAtTime: string | null;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ toggleKeyframe: () => void;
+}
+
+export function useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId,
+ animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ buildBaseUpdates,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number | string | boolean;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: number | string | boolean;
+ }) => Partial<TimelineElement>;
+}): KeyframedParamPropertyResult {
+ const editor = useEditor();
+ const propertyPath = buildGraphicParamPath({ paramKey: param.key });
+ const hasAnimatedKeyframes = hasKeyframesForPath({
+ animations,
+ propertyPath,
+ });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time: localTime,
+ })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value,
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({
+ param,
+ }),
+ numericRange:
+ param.type === "number"
+ ? { min: param.min, max: param.max, step: param.step }
+ : undefined,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId: keyframeIdAtTime,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ return {
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ onPreview: previewValue,
+ onCommit: () => editor.timeline.commitPreview(),
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
new file mode 100644
index 00000000..188f05cb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
@@ -0,0 +1,190 @@
+import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ VectorValue,
+} from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
+import { usePropertyDraft } from "./use-property-draft";
+
+export function useKeyframedVectorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ displayX,
+ displayY,
+ parseComponent,
+ step,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: VectorValue;
+ displayX: string;
+ displayY: string;
+ parseComponent: (input: string) => number | null;
+ step?: number;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: VectorValue;
+ }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+ const snapComponentValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({
+ animations,
+ propertyPath,
+ });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewVector = ({ value }: { value: VectorValue }) => {
+ const nextValue = {
+ x: snapComponentValue(value.x),
+ y: snapComponentValue(value.y),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: nextValue,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }],
+ });
+ };
+
+ const x = usePropertyDraft({
+ displayValue: displayX,
+ parse: (input) => {
+ const parsedValue = parseComponent(input);
+ return parsedValue === null ? null : snapComponentValue(parsedValue);
+ },
+ onPreview: (xVal) =>
+ previewVector({ value: { x: xVal, y: resolvedValue.y } }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const y = usePropertyDraft({
+ displayValue: displayY,
+ parse: (input) => {
+ const parsedValue = parseComponent(input);
+ return parsedValue === null ? null : snapComponentValue(parsedValue);
+ },
+ onPreview: (yVal) =>
+ previewVector({ value: { x: resolvedValue.x, y: yVal } }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ const commitX = ({ value }: { value: number }) => {
+ const vector: VectorValue = {
+ x: snapComponentValue(value),
+ y: snapComponentValue(resolvedValue.y),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: vector },
+ ],
+ });
+ return;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ const commitY = ({ value }: { value: number }) => {
+ const vector: VectorValue = {
+ x: snapComponentValue(resolvedValue.x),
+ y: snapComponentValue(value),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: vector },
+ ],
+ });
+ return;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ return {
+ x,
+ y,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitX,
+ commitY,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index cb45996d..3f3ff1f3 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -1,86 +1,95 @@
"use client";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { AudioProperties } from "./audio-properties";
-import { VideoProperties } from "./video-properties";
-import { TextProperties } from "./text-properties";
-import { EffectProperties } from "./effect-properties";
-import { ClipEffectsProperties } from "./clip-effects-properties";
-import { EmptyView } from "./empty-view";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { useEditor } from "@/hooks/use-editor";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { usePropertiesStore } from "@/stores/properties-store";
-import { isVisualElement } from "@/lib/timeline";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
-
-function ElementProperties({
- track,
- element,
-}: {
- track: TimelineTrack;
- element: TimelineElement;
-}) {
- if (element.type === "text") {
- return <TextProperties element={element} trackId={track.id} />;
- }
- if (element.type === "audio") {
- return <AudioProperties />;
- }
- if (
- element.type === "video" ||
- element.type === "image" ||
- element.type === "sticker"
- ) {
- return <VideoProperties element={element} trackId={track.id} />;
- }
- if (element.type === "effect") {
- return <EffectProperties element={element} trackId={track.id} />;
- }
- return null;
-}
+import { usePropertiesStore } from "./stores/properties-store";
+import { getPropertiesConfig } from "./registry";
+import { cn } from "@/utils/ui";
+import { EmptyView } from "./empty-view";
export function PropertiesPanel() {
const editor = useEditor();
+ useEditor((e) => e.timeline.getTracks());
+ useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
- const clipEffectsTarget = usePropertiesStore(
- (state) => state.clipEffectsTarget,
- );
+ const { activeTabPerType, setActiveTab } = usePropertiesStore();
- const clipEffectsTrack = clipEffectsTarget
- ? editor.timeline.getTrackById({ trackId: clipEffectsTarget.trackId })
- : null;
- const clipEffectsElement = clipEffectsTrack?.elements.find(
- (element) => element.id === clipEffectsTarget?.elementId,
- );
- const isShowingClipEffects =
- clipEffectsTrack &&
- clipEffectsElement &&
- isVisualElement(clipEffectsElement);
+ if (selectedElements.length === 0) {
+ return (
+ <div className="panel bg-background flex h-full flex-col items-center justify-center overflow-hidden rounded-sm border">
+ <EmptyView />
+ </div>
+ );
+ }
+
+ if (selectedElements.length > 1) {
+ return (
+ <div className="panel bg-background flex h-full flex-col items-center justify-center overflow-hidden rounded-sm border">
+ <p className="text-muted-foreground text-sm">
+ {selectedElements.length} elements selected.0
+ </p>
+ </div>
+ );
+ }
+
+ const mediaAssets = editor.media.getAssets();
const elementsWithTracks = editor.timeline.getElementsWithTracks({
elements: selectedElements,
});
+ const elementWithTrack = elementsWithTracks[0];
+
+ if (!elementWithTrack) return null;
+
+ const { element, track } = elementWithTrack;
+ const config = getPropertiesConfig({ element, mediaAssets });
+ const visibleTabs = config.tabs;
+
+ const storedTabId = activeTabPerType[element.type];
+ const isStoredTabVisible = visibleTabs.some((t) => t.id === storedTabId);
+ const activeTabId = isStoredTabVisible ? storedTabId : config.defaultTab;
+ const activeTab =
+ visibleTabs.find((t) => t.id === activeTabId) ?? visibleTabs[0];
+
+ if (!activeTab) return null;
return (
- <div className="panel bg-background h-full rounded-sm border overflow-hidden">
- {isShowingClipEffects ? (
- <ClipEffectsProperties
- element={clipEffectsElement}
- trackId={clipEffectsTrack.id}
- />
- ) : selectedElements.length > 0 ? (
- <ScrollArea className="h-full scrollbar-hidden">
- {elementsWithTracks.map(({ track, element }) => (
- <ElementProperties
- key={element.id}
- track={track}
- element={element}
- />
+ <div className="panel bg-background flex h-full overflow-hidden rounded-sm border">
+ <TooltipProvider delayDuration={0}>
+ <div className="flex shrink-0 flex-col gap-0.5 border-r p-1 scrollbar-hidden overflow-y-auto">
+ {visibleTabs.map((tab) => (
+ <Tooltip key={tab.id}>
+ <TooltipTrigger asChild>
+ <Button
+ variant={tab.id === activeTab.id ? "secondary" : "ghost"}
+ size="icon"
+ onClick={() => setActiveTab(element.type, tab.id)}
+ aria-label={tab.label}
+ className={cn(
+ "shrink-0",
+ "h-8 w-8",
+ tab.id !== activeTab.id && "text-muted-foreground",
+ )}
+ >
+ {tab.icon}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="right">{tab.label}</TooltipContent>
+ </Tooltip>
))}
- </ScrollArea>
- ) : (
- <EmptyView />
- )}
+ </div>
+ </TooltipProvider>
+ <ScrollArea className="flex-1 scrollbar-hidden">
+ {activeTab.content({ trackId: track.id })}
+ </ScrollArea>
</div>
);
}
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx
new file mode 100644
index 00000000..706faef4
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/registry.tsx
@@ -0,0 +1,303 @@
+import type { ReactNode } from "react";
+import type {
+ EffectElement,
+ GraphicElement,
+ ImageElement,
+ MaskableElement,
+ RetimableElement,
+ StickerElement,
+ TextElement,
+ VisualElement,
+ VideoElement,
+ AudioElement,
+ TimelineElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ TextFontIcon,
+ ArrowExpandIcon,
+ RainDropIcon,
+ MusicNote03Icon,
+ MagicWand05Icon,
+ DashboardSpeed02Icon,
+} from "@hugeicons/core-free-icons";
+import { TransformTab } from "./tabs/transform-tab";
+import { BlendingTab } from "./tabs/blending-tab";
+import { AudioTab } from "./tabs/audio-tab";
+import { TextTab } from "./tabs/text-tab";
+import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
+import { MasksTab } from "./tabs/masks-tab";
+import { SpeedTab } from "./tabs/speed-tab";
+import { GraphicTab } from "./tabs/graphic-tab";
+import { OcShapesIcon } from "@opencut/ui/icons";
+
+export type TabContentProps = {
+ trackId: string;
+};
+
+export type PropertiesTabDef = {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ content: (props: TabContentProps) => ReactNode;
+};
+
+export type ElementPropertiesConfig = {
+ defaultTab: string;
+ tabs: PropertiesTabDef[];
+};
+
+function buildTransformTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "transform",
+ label: "Transform",
+ icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
+ content: ({ trackId }) => (
+ <TransformTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildBlendingTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "blending",
+ label: "Blending",
+ icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
+ content: ({ trackId }) => (
+ <BlendingTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildAudioTab({
+ element,
+}: {
+ element: AudioElement | VideoElement;
+}): PropertiesTabDef {
+ return {
+ id: "audio",
+ label: "Audio",
+ icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
+ content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildSpeedTab({
+ element,
+}: {
+ element: RetimableElement;
+}): PropertiesTabDef {
+ return {
+ id: "speed",
+ label: "Speed",
+ icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
+ content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildMasksTab({
+ element,
+}: {
+ element: MaskableElement;
+}): PropertiesTabDef {
+ return {
+ id: "masks",
+ label: "Masks",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildClipEffectsTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <ClipEffectsTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
+ return {
+ id: "text",
+ label: "Text",
+ icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
+ content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildGraphicTab({
+ element,
+}: {
+ element: GraphicElement;
+}): PropertiesTabDef {
+ return {
+ id: "graphic",
+ label: "Graphic",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildStandaloneEffectTab({
+ element,
+}: {
+ element: EffectElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <StandaloneEffectTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function getTextConfig({
+ element,
+}: {
+ element: TextElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "text",
+ tabs: [
+ buildTextTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ ],
+ };
+}
+
+function getVideoConfig({
+ element,
+ mediaAsset,
+}: {
+ element: VideoElement;
+ mediaAsset: MediaAsset | undefined;
+}): ElementPropertiesConfig {
+ const showAudioTab = mediaAsset?.hasAudio !== false;
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ ...(showAudioTab ? [buildAudioTab({ element })] : []),
+ buildSpeedTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getImageConfig({
+ element,
+}: {
+ element: ImageElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getStickerConfig({
+ element,
+}: {
+ element: StickerElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getGraphicConfig({
+ element,
+}: {
+ element: GraphicElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "graphic",
+ tabs: [
+ buildGraphicTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getAudioConfig({
+ element,
+}: {
+ element: AudioElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "audio",
+ tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
+ };
+}
+
+function getEffectConfig({
+ element,
+}: {
+ element: EffectElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "effects",
+ tabs: [buildStandaloneEffectTab({ element })],
+ };
+}
+
+export function getPropertiesConfig({
+ element,
+ mediaAssets,
+}: {
+ element: TimelineElement;
+ mediaAssets: MediaAsset[];
+}): ElementPropertiesConfig {
+ switch (element.type) {
+ case "text":
+ return getTextConfig({ element });
+ case "video": {
+ const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
+ return getVideoConfig({ element, mediaAsset });
+ }
+ case "image":
+ return getImageConfig({ element });
+ case "sticker":
+ return getStickerConfig({ element });
+ case "graphic":
+ return getGraphicConfig({ element });
+ case "audio":
+ return getAudioConfig({ element });
+ case "effect":
+ return getEffectConfig({ element });
+ }
+}
diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx
deleted file mode 100644
index 8be58f39..00000000
--- a/apps/web/src/components/editor/panels/properties/sections/index.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./transform";
-export * from "./blending";
diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
deleted file mode 100644
index 6aaaa1fe..00000000
--- a/apps/web/src/components/editor/panels/properties/sections/transform.tsx
+++ /dev/null
@@ -1,382 +0,0 @@
-import { NumberField } from "@/components/ui/number-field";
-import { useEditor } from "@/hooks/use-editor";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import type { AnimationPropertyPath } from "@/types/animation";
-import type { VisualElement } from "@/types/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "../section";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowExpandIcon,
- Link05Icon,
- RotateClockwiseIcon,
-} from "@hugeicons/core-free-icons";
-import { useState } from "react";
-import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
-import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
-import { KeyframeToggle } from "../keyframe-toggle";
-import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
-
-export function parseNumericInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : parsed;
-}
-
-export function isPropertyAtDefault({
- hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue,
- staticValue,
- defaultValue,
-}: {
- hasAnimatedKeyframes: boolean;
- isPlayheadWithinElementRange: boolean;
- resolvedValue: number;
- staticValue: number;
- defaultValue: number;
-}): boolean {
- if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
- return isNearlyEqual({
- leftValue: resolvedValue,
- rightValue: defaultValue,
- });
- }
-
- return staticValue === defaultValue;
-}
-
-export function TransformSection({
- element,
- trackId,
- showTopBorder = true,
-}: {
- element: VisualElement;
- trackId: string;
- showTopBorder?: boolean;
-}) {
- const editor = useEditor();
- const [isScaleLocked, setIsScaleLocked] = useState(false);
- const playheadTime = editor.playback.getCurrentTime();
- const localTime = getElementLocalTime({
- timelineTime: playheadTime,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- const isPlayheadWithinElementRange =
- playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
- playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
-
- const positionX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.position.x",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.x).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.x,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- x: value,
- },
- },
- }),
- });
-
- const positionY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.position.y",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.y).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.y,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: {
- ...element.transform.position,
- y: value,
- },
- },
- }),
- });
-
- const scale = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scale",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scale * 100).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return Math.max(parsed, 1) / 100;
- },
- valueAtPlayhead: resolvedTransform.scale,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scale: value,
- },
- }),
- });
- const scaleFieldProps = {
- className: "flex-1",
- value: scale.displayValue,
- onFocus: scale.onFocus,
- onChange: scale.onChange,
- onBlur: scale.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scale.scrubTo,
- onScrubEnd: scale.commitScrub,
- onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scale,
- staticValue: element.transform.scale,
- defaultValue: DEFAULT_TRANSFORM.scale,
- }),
- };
-
- const rotation = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.rotate",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.rotate).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return clamp({ value: parsed, min: -360, max: 360 });
- },
- valueAtPlayhead: resolvedTransform.rotate,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- rotate: value,
- },
- }),
- });
-
- const hasPositionKeyframe =
- positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
-
- const togglePositionKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
- const keyframesToRemove: Array<{
- trackId: string;
- elementId: string;
- propertyPath: AnimationPropertyPath;
- keyframeId: string;
- }> = [];
- if (positionX.keyframeIdAtTime) {
- keyframesToRemove.push({
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.x" as const,
- keyframeId: positionX.keyframeIdAtTime,
- });
- }
- if (positionY.keyframeIdAtTime) {
- keyframesToRemove.push({
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.y" as const,
- keyframeId: positionY.keyframeIdAtTime,
- });
- }
-
- editor.timeline.removeKeyframes({
- keyframes: keyframesToRemove,
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.x",
- time: localTime,
- value: resolvedTransform.position.x,
- },
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.position.y",
- time: localTime,
- value: resolvedTransform.position.y,
- },
- ],
- });
- };
-
- return (
- <Section
- collapsible
- sectionKey={`${element.type}:transform`}
- showTopBorder={showTopBorder}
- >
- <SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField
- label="Scale"
- beforeLabel={
- <KeyframeToggle
- isActive={scale.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle scale keyframe"
- onToggle={scale.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- {isScaleLocked ? (
- <>
- <NumberField icon="W" {...scaleFieldProps} />
- <NumberField icon="H" {...scaleFieldProps} />
- </>
- ) : (
- <NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldProps}
- className="flex-1"
- />
- )}
- <Button
- type="button"
- variant={isScaleLocked ? "secondary" : "ghost"}
- size="icon"
- aria-pressed={isScaleLocked}
- onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
- >
- <HugeiconsIcon icon={Link05Icon} />
- </Button>
- </div>
- </SectionField>
- <SectionField
- label="Position"
- beforeLabel={
- <KeyframeToggle
- isActive={hasPositionKeyframe}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle position keyframe"
- onToggle={togglePositionKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon="X"
- className="flex-1"
- value={positionX.displayValue}
- onFocus={positionX.onFocus}
- onChange={positionX.onChange}
- onBlur={positionX.onBlur}
- onScrub={positionX.scrubTo}
- onScrubEnd={positionX.commitScrub}
- onReset={() =>
- positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.x,
- staticValue: element.transform.position.x,
- defaultValue: DEFAULT_TRANSFORM.position.x,
- })}
- />
- <NumberField
- icon="Y"
- className="flex-1"
- value={positionY.displayValue}
- onFocus={positionY.onFocus}
- onChange={positionY.onChange}
- onBlur={positionY.onBlur}
- onScrub={positionY.scrubTo}
- onScrubEnd={positionY.commitScrub}
- onReset={() =>
- positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.y,
- staticValue: element.transform.position.y,
- defaultValue: DEFAULT_TRANSFORM.position.y,
- })}
- />
- </div>
- </SectionField>
-
- <SectionField
- label="Rotation"
- beforeLabel={
- <KeyframeToggle
- isActive={rotation.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle rotation keyframe"
- onToggle={rotation.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- className="flex-none"
- value={rotation.displayValue}
- onFocus={rotation.onFocus}
- onChange={rotation.onChange}
- onBlur={rotation.onBlur}
- dragSensitivity="slow"
- onScrub={rotation.scrubTo}
- onScrubEnd={rotation.commitScrub}
- onReset={() =>
- rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.rotate,
- staticValue: element.transform.rotate,
- defaultValue: DEFAULT_TRANSFORM.rotate,
- })}
- />
- </div>
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
new file mode 100644
index 00000000..1783e3fc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
@@ -0,0 +1,18 @@
+import { create } from "zustand";
+
+interface PropertiesState {
+ activeTabPerType: Record<string, string>;
+ setActiveTab: (elementType: string, tabId: string) => void;
+ isTransformScaleLocked: boolean;
+ setTransformScaleLocked: (locked: boolean) => void;
+}
+
+export const usePropertiesStore = create<PropertiesState>()((set) => ({
+ activeTabPerType: {},
+ setActiveTab: (elementType, tabId) =>
+ set((state) => ({
+ activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
+ })),
+ isTransformScaleLocked: false,
+ setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+}));
diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
new file mode 100644
index 00000000..41b6ec6d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx
@@ -0,0 +1,109 @@
+import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import { resolveNumberAtTime } from "@/lib/animation";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: resolvedVolume.toFixed(1),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: 0.1,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
index a50e84e7..c360817c 100644
--- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
@@ -1,13 +1,16 @@
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
-import {
- DEFAULT_BLEND_MODE,
- DEFAULT_OPACITY,
-} from "@/constants/timeline-constants";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
-import { Section, SectionContent, SectionField, SectionHeader, SectionTitle } from "../section";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
import {
Select,
SelectContent,
@@ -16,16 +19,17 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import type { BlendMode } from "@/types/rendering";
-import type { ElementType } from "@/types/timeline";
-import type { ElementAnimations } from "@/types/animation";
+import type { BlendMode } from "@/lib/rendering";
+import type { ElementType } from "@/lib/timeline";
+import type { ElementAnimations } from "@/lib/animation/types";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
-import { KeyframeToggle } from "../keyframe-toggle";
+import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/lib/animation";
-import { isPropertyAtDefault } from "./transform";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { isPropertyAtDefault } from "./transform-tab";
type BlendingElement = {
id: string;
@@ -67,7 +71,7 @@ const BLEND_MODE_GROUPS = [
],
];
-export function BlendingSection({
+export function BlendingTab({
element,
trackId,
}: {
@@ -75,13 +79,19 @@ export function BlendingSection({
trackId: string;
}) {
const editor = useEditor();
- const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
- const didSelectRef = useRef(false);
+ const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
+ const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
const committedBlendModeRef = useRef(blendMode);
- if (!editor.timeline.isPreviewActive()) {
+ if (!isPreviewActive) {
committedBlendModeRef.current = blendMode;
}
+ const {
+ onPointerLeave,
+ onOpenChange: handleBlendModeOpenChange,
+ markCommitted,
+ } = useMenuPreview();
+
const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
@@ -103,14 +113,7 @@ export function BlendingSection({
],
});
}
- didSelectRef.current = true;
- };
-
- const handleBlendModeOpenChange = (isOpen: boolean) => {
- if (!isOpen) {
- if (!didSelectRef.current) editor.timeline.discardPreview();
- didSelectRef.current = false;
- }
+ markCommitted();
};
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
@@ -137,86 +140,91 @@ export function BlendingSection({
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
valueAtPlayhead: resolvedOpacity,
+ step: 0.01,
buildBaseUpdates: ({ value }) => ({ opacity: value }),
});
return (
- <Section collapsible sectionKey={`${element.type}:blending`}>
- <SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField
- label="Opacity"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={opacity.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle opacity keyframe"
- onToggle={opacity.toggleKeyframe}
- />
- }
- >
- <NumberField
- className="w-full"
- icon={
- <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ <Section collapsible sectionKey={`${element.id}:blending`}>
+ <SectionHeader>
+ <SectionTitle>Blending</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="Opacity"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={opacity.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle opacity keyframe"
+ onToggle={opacity.toggleKeyframe}
+ />
}
- value={opacity.displayValue}
- min={0}
- max={100}
- onFocus={opacity.onFocus}
- onChange={opacity.onChange}
- onBlur={opacity.onBlur}
- onScrub={opacity.scrubTo}
- onScrubEnd={opacity.commitScrub}
- onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })}
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOpacity,
- staticValue: element.opacity,
- defaultValue: DEFAULT_OPACITY,
- })}
- dragSensitivity="slow"
- />
- </SectionField>
- <SectionField label="Blend mode" className="w-1/2">
- <Select
- value={committedBlendModeRef.current}
- onOpenChange={handleBlendModeOpenChange}
- onValueChange={commitBlendMode}
>
- <SelectTrigger
- icon={<HugeiconsIcon icon={RainDropIcon} />}
+ <NumberField
className="w-full"
+ icon={
+ <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ }
+ value={opacity.displayValue}
+ min={0}
+ max={100}
+ onFocus={opacity.onFocus}
+ onChange={opacity.onChange}
+ onBlur={opacity.onBlur}
+ onScrub={opacity.scrubTo}
+ onScrubEnd={opacity.commitScrub}
+ onReset={() =>
+ opacity.commitValue({ value: DEFAULTS.element.opacity })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOpacity,
+ staticValue: element.opacity,
+ defaultValue: DEFAULTS.element.opacity,
+ })}
+ dragSensitivity="slow"
+ />
+ </SectionField>
+ <SectionField label="Blend mode" className="w-1/2">
+ <Select
+ value={committedBlendModeRef.current}
+ onOpenChange={handleBlendModeOpenChange}
+ onValueChange={commitBlendMode}
>
- <SelectValue placeholder="Select blend mode" />
- </SelectTrigger>
- <SelectContent className="w-36">
- {BLEND_MODE_GROUPS.map((group, groupIndex) => (
- <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
- {group.map((option) => (
- <SelectItem
- key={option.value}
- value={option.value}
- onPointerEnter={() =>
- previewBlendMode({ value: option.value as BlendMode })
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </SectionField>
- </div>
- </SectionContent>
+ <SelectTrigger
+ icon={<HugeiconsIcon icon={RainDropIcon} />}
+ className="w-full"
+ >
+ <SelectValue placeholder="Select blend mode" />
+ </SelectTrigger>
+ <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
+ {BLEND_MODE_GROUPS.map((group, groupIndex) => (
+ <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
+ {group.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ onPointerEnter={() =>
+ previewBlendMode({ value: option.value as BlendMode })
+ }
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
+ <SelectSeparator />
+ ) : null}
+ </Fragment>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </div>
+ </SectionContent>
</Section>
);
}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
new file mode 100644
index 00000000..1b94ee2d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import { useState } from "react";
+import type { ParamValues } from "@/lib/params";
+import type { Effect } from "@/lib/effects/types";
+import type { EffectElement, VisualElement } from "@/lib/timeline";
+import { effectsRegistry } from "@/lib/effects";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+ SectionFields,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ MagicWand05Icon,
+} from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { Separator } from "@/components/ui/separator";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+
+export function StandaloneEffectTab({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effect: Effect = {
+ id: element.id,
+ type: element.effectType,
+ params: element.params,
+ enabled: true,
+ };
+
+ const previewParam = (key: string) => (value: number | string | boolean) => {
+ previewUpdates({
+ params: { ...(renderElement as EffectElement).params, [key]: value },
+ });
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effect</SectionTitle>
+ </div>
+ <EffectSection
+ effect={effect}
+ renderParams={(renderElement as EffectElement).params}
+ previewParam={previewParam}
+ onCommit={commit}
+ />
+ </div>
+ );
+}
+
+export function ClipEffectsTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effects: Effect[] = element.effects ?? [];
+
+ const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
+ return (
+ (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
+ ?.params ??
+ effects.find((ef) => ef.id === effectId)?.params ??
+ {}
+ );
+ };
+
+ const buildPreviewParam =
+ (effectId: string) =>
+ (key: string) =>
+ (value: number | string | boolean) => {
+ const updatedEffects = (
+ (renderElement as VisualElement).effects ?? []
+ ).map((existing) =>
+ existing.id !== effectId
+ ? existing
+ : { ...existing, params: { ...existing.params, [key]: value } },
+ );
+ previewUpdates({ effects: updatedEffects });
+ };
+
+ const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
+
+ const handleDragOver = ({
+ event,
+ index,
+ }: {
+ event: React.DragEvent;
+ index: number;
+ }) => {
+ event.preventDefault();
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = ({ toIndex }: { toIndex: number }) => {
+ if (dragIndex !== null && dragIndex !== toIndex) {
+ editor.timeline.reorderClipEffects({
+ trackId,
+ elementId: element.id,
+ fromIndex: dragIndex,
+ toIndex,
+ });
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effects</SectionTitle>
+ </div>
+ {effects.length === 0 ? (
+ <EmptyView />
+ ) : (
+ <ul className="flex flex-col">
+ {effects.map((effect, index) => {
+ const resolvedDragIndex = dragIndex ?? -1;
+ const isDragging = dragIndex === index;
+ const isDropTarget =
+ dropIndex === index && dragIndex !== null && dragIndex !== index;
+ const showTopDropIndicator =
+ isDropTarget && index < resolvedDragIndex;
+ const showBottomDropIndicator =
+ isDropTarget && index > resolvedDragIndex;
+
+ return (
+ <li
+ key={effect.id}
+ draggable
+ onDragStart={() => handleDragStart({ index })}
+ onDragOver={(event) => handleDragOver({ event, index })}
+ onDrop={() => handleDrop({ toIndex: index })}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "group list-none",
+ isDragging && "opacity-40",
+ showTopDropIndicator && "border-t-2 border-primary",
+ showBottomDropIndicator && "border-b-2 border-primary",
+ )}
+ >
+ <EffectSection
+ effect={effect}
+ renderParams={getRenderParams({ effectId: effect.id })}
+ previewParam={buildPreviewParam(effect.id)}
+ onCommit={commit}
+ onToggle={() =>
+ editor.timeline.toggleClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ onRemove={() =>
+ editor.timeline.removeClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ />
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+function EmptyView() {
+ const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
+
+ return (
+ <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-10 text-muted-foreground"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2">
+ <h3 className="font-medium text-foreground">No effects</h3>
+ <p className="text-muted-foreground text-sm text-balance max-w-44">
+ Add effects to this layer from the Assets panel.
+ </p>
+ </div>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setActiveTab("effects")}
+ >
+ Open effects
+ </Button>
+ </div>
+ );
+}
+
+function EffectSection({
+ effect,
+ renderParams,
+ previewParam,
+ onCommit,
+ onToggle,
+ onRemove,
+}: {
+ effect: Effect;
+ renderParams: ParamValues;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+ onToggle?: () => void;
+ onRemove?: () => void;
+}) {
+ const definition = effectsRegistry.get(effect.type);
+
+ return (
+ <Section
+ sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
+ showTopBorder={false}
+ >
+ <SectionHeader
+ className={cn(onToggle && "cursor-move")}
+ trailing={
+ onToggle && (
+ <div className="flex items-center gap-1">
+ <Button
+ variant={effect.enabled ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={`Toggle ${definition.name}`}
+ onClick={onToggle}
+ >
+ <HugeiconsIcon
+ icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name}`}
+ onClick={onRemove}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ )
+ }
+ >
+ <SectionTitle
+ className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
+ >
+ {definition.name}
+ </SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
+ >
+ <SectionFields>
+ {definition.params.map((param) => (
+ <div key={param.key} className="flex flex-col gap-3.5">
+ <div className="px-4">
+ <PropertyParamField
+ param={param}
+ value={renderParams[param.key] ?? param.default}
+ onPreview={previewParam(param.key)}
+ onCommit={onCommit}
+ />
+ </div>
+ <Separator />
+ </div>
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
new file mode 100644
index 00000000..22dae8cc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} from "../hooks/use-keyframed-param-property";
+import { resolveGraphicParamsAtTime } from "@/lib/animation";
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+import type { GraphicElement } from "@/lib/timeline";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible sectionKey={`${element.id}:graphic`}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {shapeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ {hasStrokeParams && (
+ <StrokeSection element={element} trackId={trackId} />
+ )}
+ </div>
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={isStrokeEnabled}
+ sectionKey={`${element.id}:stroke`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+ <HugeiconsIcon
+ icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Stroke</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
+ >
+ <SectionFields>
+ {strokeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ });
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedParams[param.key] ?? param.default}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={{
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
new file mode 100644
index 00000000..6a5d0d0b
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
@@ -0,0 +1,660 @@
+"use client";
+
+import type { MaskableElement } from "@/lib/timeline";
+import type { Mask, MaskType } from "@/lib/masks/types";
+import type { NumberParamDefinition, SelectParamDefinition } from "@/lib/params";
+import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ FeatherIcon,
+ PlusSignIcon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { ColorPicker } from "@/components/ui/color-picker";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { NumberField } from "@/components/ui/number-field";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
+import { cn } from "@/utils/ui";
+
+type MasksTabProps = {
+ element: MaskableElement;
+ trackId: string;
+};
+
+type MaskItemProps = {
+ trackId: string;
+ elementId: string;
+ mask: Mask;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+};
+
+type EmptyViewProps = {
+ onAddMask: () => void;
+};
+
+type PreviewParamHandler = (
+ key: string,
+) => (value: number | string | boolean) => void;
+
+type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
+
+export function MasksTab({ element, trackId }: MasksTabProps) {
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } =
+ useElementPreview<MaskableElement>({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+ const maskDefs = masksRegistry.getAll();
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+ const masks = element.masks ?? [];
+ const renderMasks = renderElement.masks ?? masks;
+ const hasMask = masks.length > 0;
+ const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview();
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const elementBounds = useMemo(() => {
+ const clampedTime = Math.min(
+ Math.max(currentTime, element.startTime),
+ element.startTime + element.duration - TIME_EPSILON_SECONDS,
+ );
+
+ return (
+ getVisibleElementsWithBounds({
+ tracks,
+ currentTime: clampedTime,
+ canvasSize,
+ mediaAssets,
+ }).find(
+ (item) => item.trackId === trackId && item.elementId === element.id,
+ )?.bounds ?? null
+ );
+ }, [
+ canvasSize,
+ currentTime,
+ element.duration,
+ element.id,
+ element.startTime,
+ mediaAssets,
+ trackId,
+ tracks,
+ ]);
+
+ const handleDropdownOpenChange = (open: boolean) => {
+ if (hasMask && open) return;
+ setIsDropdownOpen(open);
+ onOpenChange(open);
+ };
+
+ const previewMask = ({ maskType }: { maskType: MaskType }) => {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ masks: [
+ buildDefaultMaskInstance({
+ maskType,
+ elementSize: elementBounds
+ ? {
+ width: elementBounds.width,
+ height: elementBounds.height,
+ }
+ : undefined,
+ }),
+ ],
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ };
+
+ const commitMask = ({ maskType }: { maskType: MaskType }) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ masks: [
+ buildDefaultMaskInstance({
+ maskType,
+ elementSize: elementBounds
+ ? {
+ width: elementBounds.width,
+ height: elementBounds.height,
+ }
+ : undefined,
+ }),
+ ],
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ }
+ markCommitted();
+ setIsDropdownOpen(false);
+ };
+
+ const previewMaskParam =
+ ({ index, key }: { index: number; key: string }) =>
+ (value: number | string | boolean) => {
+ if (!renderMasks[index]) {
+ return;
+ }
+
+ const updatedMasks = renderMasks.map((existingMask, maskIndex) =>
+ maskIndex !== index
+ ? existingMask
+ : {
+ ...existingMask,
+ params: {
+ ...existingMask.params,
+ [key]: value,
+ },
+ },
+ );
+
+ previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>);
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center justify-between gap-2">
+ <SectionTitle>Masks</SectionTitle>
+ <DropdownMenu
+ open={hasMask ? false : isDropdownOpen}
+ onOpenChange={handleDropdownOpenChange}
+ >
+ {hasMask ? (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="inline-flex">
+ <Button
+ variant="ghost"
+ size="icon"
+ disabled
+ aria-label="Add mask"
+ >
+ <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
+ </Button>
+ </span>
+ </TooltipTrigger>
+ <TooltipContent className="max-w-56 text-balance">
+ Only one mask is supported right now. If you need more,
+ duplicate the clip and apply a different mask to each copy.
+ </TooltipContent>
+ </Tooltip>
+ ) : (
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon" aria-label="Add mask">
+ <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" />
+ </Button>
+ </DropdownMenuTrigger>
+ )}
+ <DropdownMenuContent className="w-40" onPointerLeave={onPointerLeave}>
+ {maskDefs.map((definition) => (
+ <DropdownMenuItem
+ key={definition.type}
+ onPointerEnter={() =>
+ previewMask({ maskType: definition.type })
+ }
+ onClick={() => commitMask({ maskType: definition.type })}
+ >
+ <HugeiconsIcon {...definition.icon} />
+ {definition.name}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {masks.length === 0 ? (
+ <EmptyView onAddMask={() => setIsDropdownOpen(true)} />
+ ) : (
+ masks.map((mask, index) => (
+ <MaskItem
+ key={mask.id}
+ trackId={trackId}
+ elementId={element.id}
+ mask={renderMasks[index] ?? mask}
+ previewParam={(paramKey) =>
+ previewMaskParam({ index, key: paramKey })
+ }
+ onCommit={commit}
+ />
+ ))
+ )}
+ </div>
+ );
+}
+
+function MaskItem({
+ trackId,
+ elementId,
+ mask,
+ previewParam,
+ onCommit,
+}: MaskItemProps) {
+ const editor = useEditor();
+ const definition = masksRegistry.get(mask.type);
+
+ return (
+ <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}>
+ <SectionHeader
+ trailing={
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Toggle ${definition.name} mask inversion`}
+ onClick={() =>
+ editor.timeline.toggleMaskInverted({
+ trackId,
+ elementId,
+ maskId: mask.id,
+ })
+ }
+ >
+ <OcMirrorIcon
+ className={cn(mask.params.inverted && "-scale-x-100")}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name} mask`}
+ onClick={() =>
+ editor.timeline.removeMask({
+ trackId,
+ elementId,
+ maskId: mask.id,
+ })
+ }
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ }
+ >
+ <div className="flex items-center gap-2">
+ <HugeiconsIcon {...definition.icon} size={14} />
+ <SectionTitle className="capitalize font-normal">
+ {definition.name}
+ </SectionTitle>
+ </div>
+ </SectionHeader>
+ <SectionContent>
+ <MaskParamsFields
+ mask={mask}
+ definition={definition}
+ previewParam={previewParam}
+ onCommit={onCommit}
+ />
+ </SectionContent>
+ </Section>
+ );
+}
+
+function MaskParamsFields({
+ mask,
+ definition,
+ previewParam,
+ onCommit,
+}: {
+ mask: Mask;
+ definition: RegisteredMaskDefinition;
+ previewParam: PreviewParamHandler;
+ onCommit: () => void;
+}) {
+ const featherParam = getNumberParamDefinition({
+ definition,
+ key: "feather",
+ });
+ const strokeWidthParam = getNumberParamDefinition({
+ definition,
+ key: "strokeWidth",
+ });
+ const previewNumberParam = (key: string) => (value: number) =>
+ previewParam(key)(value);
+ const previewStrokeColor = previewParam("strokeColor");
+ const strokeAlignParam = definition.params.find(
+ (param): param is SelectParamDefinition =>
+ param.key === "strokeAlign" && param.type === "select",
+ );
+
+ return (
+ <SectionFields>
+ {definition.features.hasPosition &&
+ "centerX" in mask.params &&
+ "centerY" in mask.params && (
+ <SectionField label="Position">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="X"
+ param={getNumberParamDefinition({
+ definition,
+ key: "centerX",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "centerX",
+ })}
+ onPreview={previewNumberParam("centerX")}
+ onCommit={onCommit}
+ />
+ <MaskNumberField
+ className="flex-1"
+ icon="Y"
+ param={getNumberParamDefinition({
+ definition,
+ key: "centerY",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "centerY",
+ })}
+ onPreview={previewNumberParam("centerY")}
+ onCommit={onCommit}
+ />
+ </div>
+ </SectionField>
+ )}
+
+ {definition.features.sizeMode === "width-height" &&
+ "width" in mask.params &&
+ "height" in mask.params && (
+ <SectionField label="Size">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="W"
+ param={getNumberParamDefinition({
+ definition,
+ key: "width",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "width",
+ })}
+ onPreview={previewNumberParam("width")}
+ onCommit={onCommit}
+ />
+ <MaskNumberField
+ className="flex-1"
+ icon="H"
+ param={getNumberParamDefinition({
+ definition,
+ key: "height",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "height",
+ })}
+ onPreview={previewNumberParam("height")}
+ onCommit={onCommit}
+ />
+ </div>
+ </SectionField>
+ )}
+
+ {definition.features.sizeMode === "uniform" && "scale" in mask.params && (
+ <SectionField label="Scale">
+ <MaskNumberField
+ icon="S"
+ param={getNumberParamDefinition({
+ definition,
+ key: "scale",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "scale",
+ })}
+ onPreview={previewNumberParam("scale")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ )}
+
+ {definition.features.hasRotation && "rotation" in mask.params && (
+ <SectionField label="Rotation">
+ <MaskNumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ param={getNumberParamDefinition({
+ definition,
+ key: "rotation",
+ })}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "rotation",
+ })}
+ onPreview={previewNumberParam("rotation")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+ )}
+
+ <SectionField label="Feather">
+ <MaskNumberField
+ icon={<HugeiconsIcon icon={FeatherIcon} />}
+ param={featherParam}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "feather",
+ })}
+ onPreview={previewNumberParam("feather")}
+ onCommit={onCommit}
+ />
+ </SectionField>
+
+ <SectionField label="Stroke">
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center gap-2">
+ <MaskNumberField
+ className="flex-1"
+ icon="W"
+ param={strokeWidthParam}
+ value={getMaskNumber({
+ params: mask.params,
+ key: "strokeWidth",
+ })}
+ onPreview={previewNumberParam("strokeWidth")}
+ onCommit={onCommit}
+ />
+ <ColorPicker
+ className=""
+ value={mask.params.strokeColor.replace(/^#/, "").toUpperCase()}
+ onChange={(color) => previewStrokeColor(`#${color}`)}
+ onChangeEnd={(color) => {
+ previewStrokeColor(`#${color}`);
+ onCommit();
+ }}
+ />
+ </div>
+ {strokeAlignParam ? (
+ <Select
+ value={mask.params.strokeAlign}
+ onValueChange={(value) => {
+ previewParam("strokeAlign")(value);
+ onCommit();
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {strokeAlignParam.options.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : null}
+ </div>
+ </SectionField>
+ </SectionFields>
+ );
+}
+
+function getNumberParamDefinition({
+ definition,
+ key,
+}: {
+ definition: RegisteredMaskDefinition;
+ key: string;
+}): NumberParamDefinition {
+ const param = definition.params.find((candidate) => candidate.key === key);
+
+ if (!param || param.type !== "number") {
+ throw new Error(`Missing number param definition for mask key "${key}"`);
+ }
+
+ return param;
+}
+
+function getMaskNumber({
+ params,
+ key,
+}: {
+ params: Mask["params"];
+ key: string;
+}): number {
+ const value = params[key];
+
+ if (typeof value !== "number") {
+ throw new Error(`Expected numeric mask param for "${key}"`);
+ }
+
+ return value;
+}
+
+function MaskNumberField({
+ param,
+ value,
+ onPreview,
+ onCommit,
+ icon,
+ className,
+}: {
+ param: NumberParamDefinition;
+ value: number;
+ onPreview: (value: number) => void;
+ onCommit: () => void;
+ icon?: React.ReactNode;
+ className?: string;
+}) {
+ const isPercent = param.unit === "percent";
+ const percentMax = param.max ?? 100;
+ const displayMultiplier = isPercent
+ ? 100 / percentMax
+ : (param.displayMultiplier ?? 1);
+ const min = isPercent ? 0 : param.min;
+ const max = isPercent ? 100 : param.max;
+ const step = isPercent ? 1 : param.step;
+ const displayValue = value * displayMultiplier;
+ const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
+
+ const clampDisplay = (nextDisplayValue: number) =>
+ max !== undefined
+ ? clamp({ value: nextDisplayValue, min, max })
+ : Math.max(min, nextDisplayValue);
+
+ const previewFromDisplay = (nextDisplayValue: number) => {
+ onPreview(
+ clampDisplay(snapToStep({ value: nextDisplayValue, step })) /
+ displayMultiplier,
+ );
+ };
+
+ const draft = usePropertyDraft({
+ displayValue: formatNumberForDisplay({
+ value: displayValue,
+ maxFractionDigits,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier;
+ },
+ onPreview,
+ onCommit,
+ });
+
+ return (
+ <NumberField
+ className={className}
+ icon={icon}
+ value={draft.displayValue}
+ dragSensitivity="slow"
+ onFocus={draft.onFocus}
+ onChange={draft.onChange}
+ onBlur={draft.onBlur}
+ onScrub={previewFromDisplay}
+ onScrubEnd={onCommit}
+ />
+ );
+}
+
+function EmptyView({ onAddMask }: EmptyViewProps) {
+ return (
+ <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
+ <OcShapesIcon className="size-10 text-muted-foreground" strokeWidth={1} />
+ <div className="flex flex-col gap-2">
+ <h3 className="font-medium text-foreground">No masks</h3>
+ <p className="text-muted-foreground text-sm text-balance max-w-40">
+ Add a mask to hide or reveal parts of this layer.
+ </p>
+ </div>
+ <Button variant="default" size="sm" onClick={onAddMask}>
+ Add mask
+ </Button>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
new file mode 100644
index 00000000..44f5dd74
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx
@@ -0,0 +1,137 @@
+import { useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
+import { buildConstantRetime } from "@/lib/retime";
+import {
+ DEFAULT_RETIME_RATE,
+ MIN_RETIME_RATE,
+ MAX_RETIME_RATE,
+ clampRetimeRate,
+ canMaintainPitch,
+} from "@/constants/retime-constants";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+
+function rateToDisplay({ rate }: { rate: number }): string {
+ return rate.toFixed(2);
+}
+
+function parseSpeedInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ const rounded = Math.round(parsed * 100) / 100;
+ return clampRetimeRate({ rate: rounded });
+}
+
+function buildRetime({
+ rate,
+ maintainPitch,
+}: {
+ rate: number;
+ maintainPitch: boolean;
+}) {
+ if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined;
+ return buildConstantRetime({ rate, maintainPitch });
+}
+
+export function SpeedTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const rate = clampRetimeRate({
+ rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
+ });
+ const maintainPitchAvailable = canMaintainPitch({ rate });
+ const maintainPitch = element.retime?.maintainPitch ?? false;
+ const pendingRateRef = useRef(rate);
+
+ const applyRetime = ({
+ rate: nextRate,
+ maintainPitch: nextMaintainPitch,
+ pushHistory = true,
+ }: {
+ rate: number;
+ maintainPitch: boolean;
+ pushHistory?: boolean;
+ }) => {
+ editor.timeline.updateElementRetime({
+ trackId,
+ elementId: element.id,
+ retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
+ pushHistory,
+ });
+ };
+
+ const speedDraft = usePropertyDraft({
+ displayValue: rateToDisplay({ rate }),
+ parse: (input) => parseSpeedInput({ input }),
+ onPreview: (nextRate) => {
+ pendingRateRef.current = nextRate;
+ applyRetime({ rate: nextRate, maintainPitch, pushHistory: false });
+ },
+ onCommit: () => {
+ applyRetime({ rate: pendingRateRef.current, maintainPitch });
+ },
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:speed`}>
+ <SectionHeader>
+ <SectionTitle>Speed</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Speed">
+ <NumberField
+ icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />}
+ value={speedDraft.displayValue}
+ suffix="x"
+ scrubRanges={[
+ { from: 0.01, to: 1, pixelsPerUnit: 160 },
+ { from: 1, to: 5, pixelsPerUnit: 48 },
+ ]}
+ scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }}
+ onFocus={() => {
+ pendingRateRef.current = rate;
+ speedDraft.onFocus();
+ }}
+ onChange={speedDraft.onChange}
+ onBlur={speedDraft.onBlur}
+ onScrub={speedDraft.scrubTo}
+ onScrubEnd={speedDraft.commitScrub}
+ onReset={() =>
+ applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
+ }
+ isDefault={rate === DEFAULT_RETIME_RATE}
+ />
+ </SectionField>
+ <div className="flex items-center justify-between">
+ <span className="text-sm">Change pitch</span>
+ <Switch
+ checked={!maintainPitch}
+ disabled={!maintainPitchAvailable}
+ onCheckedChange={(checked) =>
+ applyRetime({ rate, maintainPitch: !checked })
+ }
+ />
+ </div>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
index ae6de420..f8e2527a 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
@@ -1,6 +1,8 @@
+"use client";
+
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
-import type { TextElement } from "@/types/timeline";
+import type { TextElement } from "@/lib/timeline";
import { NumberField } from "@/components/ui/number-field";
import { useRef } from "react";
import {
@@ -10,7 +12,7 @@ import {
SectionFields,
SectionHeader,
SectionTitle,
-} from "./section";
+} from "@/components/section";
import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string";
@@ -20,31 +22,27 @@ import { DEFAULT_COLOR } from "@/constants/project-constants";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
- DEFAULT_LETTER_SPACING,
- DEFAULT_LINE_HEIGHT,
- DEFAULT_TEXT_BACKGROUND,
- DEFAULT_TEXT_ELEMENT,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "@/constants/text-constants";
-import { usePropertyDraft } from "./hooks/use-property-draft";
-import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property";
-import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "./hooks/use-element-playhead";
-import { TransformSection, BlendingSection } from "./sections";
-import { KeyframeToggle } from "./keyframe-toggle";
-import { isPropertyAtDefault } from "./sections/transform";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { isPropertyAtDefault } from "./transform-tab";
import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
import { HugeiconsIcon } from "@hugeicons/react";
import {
+ MinusSignIcon,
+ PlusSignIcon,
TextFontIcon,
- ViewIcon,
- ViewOffSlashIcon,
} from "@hugeicons/core-free-icons";
import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
+import { DEFAULTS } from "@/lib/timeline/defaults";
import { cn } from "@/utils/ui";
-export function TextProperties({
+export function TextTab({
element,
trackId,
}: {
@@ -52,10 +50,8 @@ export function TextProperties({
trackId: string;
}) {
return (
- <div className="flex h-full flex-col">
+ <div className="flex flex-col">
<ContentSection element={element} trackId={trackId} />
- <TransformSection element={element} trackId={trackId} />
- <BlendingSection element={element} trackId={trackId} />
<TypographySection element={element} trackId={trackId} />
<SpacingSection element={element} trackId={trackId} />
<BackgroundSection element={element} trackId={trackId} />
@@ -85,7 +81,7 @@ function ContentSection({
});
return (
- <Section collapsible sectionKey="text:content" showTopBorder={false}>
+ <Section collapsible sectionKey={`${element.id}:content`}>
<SectionHeader>
<SectionTitle>Content</SectionTitle>
</SectionHeader>
@@ -138,7 +134,11 @@ function TypographySection({
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
+ return clamp({
+ value: Math.round(parsed),
+ min: MIN_FONT_SIZE,
+ max: MAX_FONT_SIZE,
+ });
},
onPreview: (value) =>
editor.timeline.previewElements({
@@ -150,7 +150,7 @@ function TypographySection({
});
return (
- <Section collapsible sectionKey="text:typography">
+ <Section collapsible sectionKey={`${element.id}:typography`}>
<SectionHeader>
<SectionTitle>Typography</SectionTitle>
</SectionHeader>
@@ -188,12 +188,14 @@ function TypographySection({
{
trackId,
elementId: element.id,
- updates: { fontSize: DEFAULT_TEXT_ELEMENT.fontSize },
+ updates: {
+ fontSize: DEFAULTS.text.element.fontSize,
+ },
},
],
})
}
- isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
+ isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
icon={<HugeiconsIcon icon={TextFontIcon} />}
/>
</SectionField>
@@ -233,7 +235,7 @@ function SpacingSection({
const letterSpacing = usePropertyDraft({
displayValue: Math.round(
- element.letterSpacing ?? DEFAULT_LETTER_SPACING,
+ element.letterSpacing ?? DEFAULTS.text.letterSpacing,
).toString(),
parse: (input) => {
const parsed = parseFloat(input);
@@ -249,7 +251,7 @@ function SpacingSection({
});
const lineHeight = usePropertyDraft({
- displayValue: (element.lineHeight ?? DEFAULT_LINE_HEIGHT).toFixed(1),
+ displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed)
@@ -266,7 +268,7 @@ function SpacingSection({
});
return (
- <Section collapsible sectionKey="text:spacing" showBottomBorder={false}>
+ <Section collapsible sectionKey={`${element.id}:spacing`}>
<SectionHeader>
<SectionTitle>Spacing</SectionTitle>
</SectionHeader>
@@ -286,14 +288,14 @@ function SpacingSection({
{
trackId,
elementId: element.id,
- updates: { letterSpacing: DEFAULT_LETTER_SPACING },
+ updates: { letterSpacing: DEFAULTS.text.letterSpacing },
},
],
})
}
isDefault={
- (element.letterSpacing ?? DEFAULT_LETTER_SPACING) ===
- DEFAULT_LETTER_SPACING
+ (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
+ DEFAULTS.text.letterSpacing
}
icon={<OcTextWidthIcon size={14} />}
/>
@@ -312,14 +314,14 @@ function SpacingSection({
{
trackId,
elementId: element.id,
- updates: { lineHeight: DEFAULT_LINE_HEIGHT },
+ updates: { lineHeight: DEFAULTS.text.lineHeight },
},
],
})
}
isDefault={
- (element.lineHeight ?? DEFAULT_LINE_HEIGHT) ===
- DEFAULT_LINE_HEIGHT
+ (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
+ DEFAULTS.text.lineHeight
}
icon={<OcTextHeightIcon size={14} />}
/>
@@ -366,25 +368,25 @@ function BackgroundSection({
const bg = element.background;
const resolvedPaddingX = resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
animations: element.animations,
propertyPath: "background.paddingX",
localTime,
});
const resolvedPaddingY = resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
animations: element.animations,
propertyPath: "background.paddingY",
localTime,
});
const resolvedOffsetX = resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
animations: element.animations,
propertyPath: "background.offsetX",
localTime,
});
const resolvedOffsetY = resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
animations: element.animations,
propertyPath: "background.offsetY",
localTime,
@@ -409,6 +411,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
valueAtPlayhead: resolvedPaddingX,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, paddingX: value },
}),
@@ -427,6 +430,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
valueAtPlayhead: resolvedPaddingY,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, paddingY: value },
}),
@@ -445,6 +449,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
valueAtPlayhead: resolvedOffsetX,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, offsetX: value },
}),
@@ -463,6 +468,7 @@ function BackgroundSection({
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
valueAtPlayhead: resolvedOffsetY,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, offsetY: value },
}),
@@ -479,9 +485,14 @@ function BackgroundSection({
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
- return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX });
+ return clamp({
+ value: Math.round(parsed),
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ });
},
valueAtPlayhead: resolvedCornerRadius,
+ step: 1,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, cornerRadius: value },
}),
@@ -514,7 +525,7 @@ function BackgroundSection({
<Section
collapsible
defaultOpen={element.background.enabled}
- sectionKey="text:background"
+ sectionKey={`${element.id}:background`}
>
<SectionHeader
trailing={
@@ -527,7 +538,8 @@ function BackgroundSection({
}}
>
<HugeiconsIcon
- icon={element.background.enabled ? ViewIcon : ViewOffSlashIcon}
+ icon={element.background.enabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
/>
</Button>
}
@@ -590,13 +602,17 @@ function BackgroundSection({
onBlur={paddingX.onBlur}
onScrub={paddingX.scrubTo}
onScrubEnd={paddingX.commitScrub}
- onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
+ onReset={() =>
+ paddingX.commitValue({
+ value: DEFAULTS.text.background.paddingX,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedPaddingX,
- staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
- defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
+ staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ defaultValue: DEFAULTS.text.background.paddingX,
})}
/>
</SectionField>
@@ -621,13 +637,17 @@ function BackgroundSection({
onBlur={paddingY.onBlur}
onScrub={paddingY.scrubTo}
onScrubEnd={paddingY.commitScrub}
- onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })}
+ onReset={() =>
+ paddingY.commitValue({
+ value: DEFAULTS.text.background.paddingY,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedPaddingY,
- staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
- defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY,
+ staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ defaultValue: DEFAULTS.text.background.paddingY,
})}
/>
</SectionField>
@@ -653,13 +673,17 @@ function BackgroundSection({
onBlur={offsetX.onBlur}
onScrub={offsetX.scrubTo}
onScrubEnd={offsetX.commitScrub}
- onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })}
+ onReset={() =>
+ offsetX.commitValue({
+ value: DEFAULTS.text.background.offsetX,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOffsetX,
- staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
- defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX,
+ staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ defaultValue: DEFAULTS.text.background.offsetX,
})}
/>
</SectionField>
@@ -683,13 +707,17 @@ function BackgroundSection({
onBlur={offsetY.onBlur}
onScrub={offsetY.scrubTo}
onScrubEnd={offsetY.commitScrub}
- onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })}
+ onReset={() =>
+ offsetY.commitValue({
+ value: DEFAULTS.text.background.offsetY,
+ })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOffsetY,
- staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
- defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY,
+ staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ defaultValue: DEFAULTS.text.background.offsetY,
})}
/>
</SectionField>
@@ -715,7 +743,9 @@ function BackgroundSection({
onBlur={cornerRadius.onBlur}
onScrub={cornerRadius.scrubTo}
onScrubEnd={cornerRadius.commitScrub}
- onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })}
+ onReset={() =>
+ cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
+ }
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
new file mode 100644
index 00000000..a015a9bb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx
@@ -0,0 +1,424 @@
+import { NumberField } from "@/components/ui/number-field";
+import { useEditor } from "@/hooks/use-editor";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { VisualElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowExpandIcon,
+ Link05Icon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
+import { usePropertiesStore } from "../stores/properties-store";
+
+export function parseNumericInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function isPropertyAtDefault({
+ hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ staticValue,
+ defaultValue,
+}: {
+ hasAnimatedKeyframes: boolean;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number;
+ staticValue: number;
+ defaultValue: number;
+}): boolean {
+ if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
+ return isNearlyEqual({
+ leftValue: resolvedValue,
+ rightValue: defaultValue,
+ });
+ }
+
+ return staticValue === defaultValue;
+}
+
+export function TransformTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
+ const setTransformScaleLocked = usePropertiesStore(
+ (s) => s.setTransformScaleLocked,
+ );
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const position = useKeyframedVectorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position,
+ displayX: Math.round(resolvedTransform.position.x).toString(),
+ displayY: Math.round(resolvedTransform.position.y).toString(),
+ parseComponent: (input) => parseNumericInput({ input }),
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: { ...element.transform, position: value },
+ }),
+ });
+
+ const parseScale = (input: string) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return parsed / 100;
+ };
+
+ const scaleX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleX,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleX: value,
+ ...(isScaleLocked ? { scaleY: value } : {}),
+ },
+ }),
+ });
+
+ const scaleY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleY,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleY: value,
+ ...(isScaleLocked ? { scaleX: value } : {}),
+ },
+ }),
+ });
+
+ const scaleFieldPropsX = {
+ value: scaleX.displayValue,
+ onFocus: scaleX.onFocus,
+ onChange: scaleX.onChange,
+ onBlur: scaleX.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleX.scrubTo,
+ onScrubEnd: scaleX.commitScrub,
+ onReset: () =>
+ scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleX,
+ staticValue: element.transform.scaleX,
+ defaultValue: DEFAULTS.element.transform.scaleX,
+ }),
+ };
+
+ const scaleFieldPropsY = {
+ value: scaleY.displayValue,
+ onFocus: scaleY.onFocus,
+ onChange: scaleY.onChange,
+ onBlur: scaleY.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleY.scrubTo,
+ onScrubEnd: scaleY.commitScrub,
+ onReset: () =>
+ scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleY,
+ staticValue: element.transform.scaleY,
+ defaultValue: DEFAULTS.element.transform.scaleY,
+ }),
+ };
+
+ const rotation = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.rotate",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.rotate).toString(),
+ parse: (input) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return clamp({ value: parsed, min: -360, max: 360 });
+ },
+ valueAtPlayhead: resolvedTransform.rotate,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ rotate: value,
+ },
+ }),
+ });
+
+ const hasScaleKeyframe = hasGroupKeyframeAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+
+ const toggleScaleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+ const existing = getGroupKeyframesAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+ if (existing.length > 0) {
+ editor.timeline.removeKeyframes({
+ keyframes: existing.map((ref) => ({
+ trackId,
+ elementId: element.id,
+ ...ref,
+ })),
+ });
+ return;
+ }
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleX",
+ time: localTime,
+ value: resolvedTransform.scaleX,
+ },
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleY",
+ time: localTime,
+ value: resolvedTransform.scaleY,
+ },
+ ],
+ });
+ };
+
+ const scaleLockButton = (
+ <Button
+ type="button"
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setTransformScaleLocked(!isScaleLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ );
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:transform`}>
+ <SectionHeader>
+ <SectionTitle>Transform</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <div className="flex items-end gap-2">
+ {isScaleLocked ? (
+ <>
+ <SectionField
+ label="Scale"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={hasScaleKeyframe}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle scale keyframe"
+ onToggle={toggleScaleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldPropsX}
+ />
+ </SectionField>
+ {scaleLockButton}
+ </>
+ ) : (
+ <>
+ <SectionField
+ label="Width"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle width scale keyframe"
+ onToggle={scaleX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="W" {...scaleFieldPropsX} />
+ </SectionField>
+ {scaleLockButton}
+ <SectionField
+ label="Height"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle height scale keyframe"
+ onToggle={scaleY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="H" {...scaleFieldPropsY} />
+ </SectionField>
+ </>
+ )}
+ </div>
+ <SectionField
+ label="Position"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={position.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle position keyframe"
+ onToggle={position.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon="X"
+ className="flex-1"
+ value={position.x.displayValue}
+ onFocus={position.x.onFocus}
+ onChange={position.x.onChange}
+ onBlur={position.x.onBlur}
+ onScrub={position.x.scrubTo}
+ onScrubEnd={position.x.commitScrub}
+ onReset={() =>
+ position.commitX({
+ value: DEFAULTS.element.transform.position.x,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.x,
+ staticValue: element.transform.position.x,
+ defaultValue: DEFAULTS.element.transform.position.x,
+ })}
+ />
+ <NumberField
+ icon="Y"
+ className="flex-1"
+ value={position.y.displayValue}
+ onFocus={position.y.onFocus}
+ onChange={position.y.onChange}
+ onBlur={position.y.onBlur}
+ onScrub={position.y.scrubTo}
+ onScrubEnd={position.y.commitScrub}
+ onReset={() =>
+ position.commitY({
+ value: DEFAULTS.element.transform.position.y,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.y,
+ staticValue: element.transform.position.y,
+ defaultValue: DEFAULTS.element.transform.position.y,
+ })}
+ />
+ </div>
+ </SectionField>
+
+ <SectionField
+ label="Rotation"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={rotation.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle rotation keyframe"
+ onToggle={rotation.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ className="flex-none"
+ value={rotation.displayValue}
+ onFocus={rotation.onFocus}
+ onChange={rotation.onChange}
+ onBlur={rotation.onBlur}
+ dragSensitivity="slow"
+ onScrub={rotation.scrubTo}
+ onScrubEnd={rotation.commitScrub}
+ onReset={() =>
+ rotation.commitValue({
+ value: DEFAULTS.element.transform.rotate,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.rotate,
+ staticValue: element.transform.rotate,
+ defaultValue: DEFAULTS.element.transform.rotate,
+ })}
+ />
+ </div>
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx
deleted file mode 100644
index 49dd8fe5..00000000
--- a/apps/web/src/components/editor/panels/properties/video-properties.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import type {
- ImageElement,
- StickerElement,
- VideoElement,
-} from "@/types/timeline";
-import { BlendingSection, TransformSection } from "./sections";
-
-export function VideoProperties({
- element,
- trackId,
-}: {
- element: VideoElement | ImageElement | StickerElement;
- trackId: string;
-}) {
- return (
- <div className="flex h-full flex-col">
- <TransformSection
- element={element}
- trackId={trackId}
- showTopBorder={false}
- />
- <BlendingSection element={element} trackId={trackId} />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
index bbc5cb83..cbb19bd0 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
@@ -1,176 +1,167 @@
-import { useEffect, useRef, useState } from "react";
-import WaveSurfer from "wavesurfer.js";
+"use client";
+import { useCallback, useEffect, useRef } from "react";
+import { useResizeObserver } from "@/hooks/use-resize-observer";
+import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio";
+import { findScrollParent } from "@/utils/browser";
+import { cn } from "@/utils/ui";
+
+const BAR_WIDTH = 2;
+const BAR_GAP = 1;
+const BAR_STEP = BAR_WIDTH + BAR_GAP;
interface AudioWaveformProps {
audioUrl?: string;
audioBuffer?: AudioBuffer;
- height?: number;
+ color?: string;
className?: string;
}
-function extractPeaks({
- buffer,
- length = 512,
-}: {
- buffer: AudioBuffer;
- length?: number;
-}): number[][] {
- const channels = buffer.numberOfChannels;
- const peaks: number[][] = [];
-
- for (let c = 0; c < channels; c++) {
- const data = buffer.getChannelData(c);
- const step = Math.floor(data.length / length);
- const channelPeaks: number[] = [];
-
- for (let i = 0; i < length; i++) {
- const start = i * step;
- const end = Math.min(start + step, data.length);
- let max = 0;
- for (let j = start; j < end; j++) {
- const abs = Math.abs(data[j]);
- if (abs > max) max = abs;
- }
- channelPeaks.push(max);
- }
- peaks.push(channelPeaks);
- }
-
- return peaks;
-}
-
export function AudioWaveform({
audioUrl,
audioBuffer,
- height = 32,
+ color = "rgba(255, 255, 255, 0.7)",
className = "",
}: AudioWaveformProps) {
- const waveformRef = useRef<HTMLDivElement>(null);
- const wavesurfer = useRef<WaveSurfer | null>(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(false);
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const bufferRef = useRef<AudioBuffer | null>(null);
+ const globalMaxRef = useRef<number>(1);
+ const scrollParentRef = useRef<HTMLElement | null>(null);
+ const heightRef = useRef<number>(0);
- useEffect(() => {
- let mounted = true;
- const ws = wavesurfer.current;
+ const drawVisible = useCallback(() => {
+ const container = containerRef.current;
+ const canvas = canvasRef.current;
+ const buffer = bufferRef.current;
+ const height = heightRef.current;
- const initWaveSurfer = async () => {
- if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
+ if (!container || !canvas || !buffer || height <= 0) return;
- try {
- if (ws) {
- wavesurfer.current = null;
- }
+ const elementWidth = container.offsetWidth;
+ if (elementWidth <= 0) return;
- const newWaveSurfer = WaveSurfer.create({
- container: waveformRef.current,
- waveColor: "rgba(255, 255, 255, 0.6)",
- progressColor: "rgba(255, 255, 255, 0.9)",
- cursorColor: "transparent",
- barWidth: 2,
- barGap: 1,
- height,
- normalize: true,
- interact: false,
- });
+ const containerRect = container.getBoundingClientRect();
+ const scrollParent = scrollParentRef.current;
- if (mounted) {
- wavesurfer.current = newWaveSurfer;
- } else {
- try {
- newWaveSurfer.destroy();
- } catch {}
- return;
- }
+ let clipLeft: number;
+ let clipRight: number;
- newWaveSurfer.on("ready", () => {
- if (mounted) {
- setIsLoading(false);
- setError(false);
- }
- });
+ if (scrollParent) {
+ const parentRect = scrollParent.getBoundingClientRect();
+ clipLeft = Math.max(0, parentRect.left - containerRect.left);
+ clipRight = Math.min(elementWidth, parentRect.right - containerRect.left);
+ } else {
+ clipLeft = Math.max(0, -containerRect.left);
+ clipRight = Math.min(
+ elementWidth,
+ window.innerWidth - containerRect.left,
+ );
+ }
- newWaveSurfer.on("error", (err) => {
- if (mounted) {
- console.error("WaveSurfer error:", err);
- setError(true);
- setIsLoading(false);
- }
- });
+ const visibleWidth = clipRight - clipLeft;
+ if (visibleWidth <= 0) return;
- if (audioBuffer) {
- const peaks = extractPeaks({ buffer: audioBuffer });
- newWaveSurfer.load("", peaks, audioBuffer.duration);
- } else if (audioUrl) {
- await newWaveSurfer.load(audioUrl);
- }
- } catch (err) {
- if (mounted) {
- console.error("Failed to initialize WaveSurfer:", err);
- setError(true);
- setIsLoading(false);
- }
- }
- };
+ const dpr = window.devicePixelRatio || 1;
+ const canvasW = Math.round(visibleWidth * dpr);
+ const canvasH = Math.round(height * dpr);
+
+ if (canvasW <= 0 || canvasH <= 0) return;
+
+ canvas.width = canvasW;
+ canvas.height = canvasH;
+ canvas.style.width = `${visibleWidth}px`;
+ canvas.style.height = `${height}px`;
+ canvas.style.left = `${clipLeft}px`;
+
+ const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
+ const startFraction = clipLeft / elementWidth;
+ const endFraction = clipRight / elementWidth;
+ const startSample = Math.floor(startFraction * buffer.length);
+ const endSample = Math.min(
+ buffer.length,
+ Math.ceil(endFraction * buffer.length),
+ );
- if (ws) {
- const wsToDestroy = ws;
- wavesurfer.current = null;
+ const peaks = extractRmsRange({
+ buffer,
+ count: barCount,
+ startSample,
+ endSample,
+ globalMax: globalMaxRef.current,
+ });
- requestAnimationFrame(() => {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ ctx.clearRect(0, 0, canvasW, canvasH);
+ ctx.scale(dpr, dpr);
+ ctx.fillStyle = color;
+
+ const maxBarHeight = height * 0.7;
+
+ for (let i = 0; i < barCount; i++) {
+ const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
+ const barH = Math.max(1, scaled * maxBarHeight);
+ ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
+ }
+ }, [color]);
+
+ useEffect(() => {
+ let isCancelled = false;
+
+ async function load() {
+ let buffer = audioBuffer ?? null;
+
+ if (!buffer && audioUrl) {
try {
- wsToDestroy.destroy();
- } catch {}
- if (mounted) {
- initWaveSurfer();
+ const resp = await fetch(audioUrl);
+ const arrayBuffer = await resp.arrayBuffer();
+ const actx = new AudioContext();
+ buffer = await actx.decodeAudioData(arrayBuffer);
+ actx.close();
+ } catch {
+ return;
}
- });
- } else {
- initWaveSurfer();
+ }
+
+ if (!buffer || isCancelled) return;
+
+ bufferRef.current = buffer;
+ globalMaxRef.current = computeGlobalMaxRms({ buffer });
+ drawVisible();
}
+ load();
return () => {
- mounted = false;
+ isCancelled = true;
+ };
+ }, [audioUrl, audioBuffer, drawVisible]);
- const wsToDestroy = wavesurfer.current;
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
- wavesurfer.current = null;
+ scrollParentRef.current = findScrollParent({ element: container });
+ const scrollParent = scrollParentRef.current;
+ if (!scrollParent) return;
- if (wsToDestroy) {
- requestAnimationFrame(() => {
- try {
- wsToDestroy.destroy();
- } catch {}
- });
- }
- };
- }, [audioUrl, audioBuffer, height]);
+ scrollParent.addEventListener("scroll", drawVisible, { passive: true });
+ return () => scrollParent.removeEventListener("scroll", drawVisible);
+ }, [drawVisible]);
- if (error) {
- return (
- <div
- className={`flex items-center justify-center ${className}`}
- style={{ height }}
- >
- <span className="text-foreground/60 text-xs">Audio unavailable</span>
- </div>
- );
- }
+ const onResize = useCallback(
+ (entry: ResizeObserverEntry) => {
+ heightRef.current = entry.contentRect.height;
+ drawVisible();
+ },
+ [drawVisible],
+ );
+
+ useResizeObserver({ ref: containerRef, onResize });
return (
- <div className={`relative ${className}`}>
- {isLoading && (
- <div className="absolute inset-0 flex items-center justify-center">
- <span className="text-foreground/60 text-xs">Loading...</span>
- </div>
- )}
- <div
- ref={waveformRef}
- className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`}
- style={{ height }}
- />
+ <div ref={containerRef} className={cn("relative size-full", className)}>
+ <canvas ref={canvasRef} className="absolute bottom-0" />
</div>
);
}
-
-export default AudioWaveform;
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
index 864bb557..7000f98b 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -5,7 +5,10 @@ import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
-import { DEFAULT_BOOKMARK_COLOR } from "@/constants/timeline-constants";
+import {
+ DEFAULT_BOOKMARK_COLOR,
+ TIMELINE_BOOKMARK_ROW_HEIGHT,
+} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
import {
@@ -13,7 +16,7 @@ import {
Delete02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import type { Bookmark } from "@/types/timeline";
+import type { Bookmark } from "@/lib/timeline";
import {
Popover,
PopoverAnchor,
@@ -25,7 +28,10 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
-import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/lib/timeline";
+import {
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
@@ -72,14 +78,17 @@ export function TimelineBookmarksRow({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineBookmarksRowProps) {
- const editor = useEditor();
- const activeScene = editor.scenes.getActiveScene();
+ const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks);
return (
- <div className="relative h-4 flex-1 overflow-hidden">
+ <div
+ className="relative flex-1 overflow-hidden"
+ style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }}
+ >
<button
- className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
+ className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
style={{
+ height: TIMELINE_BOOKMARK_ROW_HEIGHT,
width: `${dynamicTimelineWidth}px`,
}}
aria-label="Timeline ruler"
@@ -95,7 +104,7 @@ export function TimelineBookmarksRow({
handleRulerTrackingMouseDown(event);
}}
>
- {activeScene.bookmarks.map((bookmark) => (
+ {bookmarks.map((bookmark) => (
<TimelineBookmark
key={`bookmark-${bookmark.time}`}
bookmark={bookmark}
@@ -174,7 +183,7 @@ function TimelineBookmark({
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverAnchor asChild>
<button
- className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
+ className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0"
style={{
left: `${bookmarkLeft}px`,
width: `${bookmarkWidth}px`,
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
index 291a4633..7597a620 100644
--- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx
+++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx
@@ -1,5 +1,6 @@
import { getDropLineY } from "@/lib/timeline/drop-utils";
-import type { TimelineTrack, DropTarget } from "@/types/timeline";
+import type { TimelineTrack, DropTarget } from "@/lib/timeline";
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
interface DragLineProps {
dropTarget: DropTarget | null;
@@ -22,7 +23,7 @@ export function DragLine({
return (
<div
className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
- style={{ top: `${lineTop}px` }}
+ style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }}
/>
);
}
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 593976a8..f1ecd9f4 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -3,31 +3,44 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Delete02Icon,
+ MagicWand05Icon,
+ MusicNote03Icon,
TaskAdd02Icon,
+ TextIcon,
ViewIcon,
ViewOffSlashIcon,
VolumeHighIcon,
VolumeOffIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
-} from "../../../ui/context-menu";
+} from "@/components/ui/context-menu";
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
-import { useState, useRef, useCallback } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import type { ElementDragState, DropTarget } from "@/lib/timeline";
import { TimelineTrackContent } from "./timeline-track";
import { TimelinePlayhead } from "./timeline-playhead";
-import { SelectionBox } from "../../selection-box";
-import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
+import { SelectionBox } from "@/lib/selection/selection-box";
+import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
import { SnapIndicator } from "./snap-indicator";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import {
TIMELINE_CONSTANTS,
- TRACK_CONFIG,
+ TRACK_GAP,
+ TIMELINE_SCROLLBAR_SIZE_PX,
} from "@/constants/timeline-constants";
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
import {
@@ -36,12 +49,11 @@ import {
getTotalTracksHeight,
canTracktHaveAudio,
canTrackBeHidden,
+ isMainTrack,
getTimelineZoomMin,
getTimelinePaddingPx,
- isMainTrack,
} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
-import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
@@ -49,27 +61,58 @@ import { TimelineRuler } from "./timeline-ruler";
import { TimelineBookmarksRow } from "./bookmarks";
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
+import { useInitialScrollBottom } from "@/hooks/timeline/use-initial-scroll-bottom";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/lib/actions";
+import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing";
+import { cn } from "@/utils/ui";
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
const FALLBACK_CONTAINER_WIDTH = 1000;
+const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
+const TRACK_ICONS: Record<TimelineTrack["type"], ReactNode> = {
+ video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
+ text: (
+ <HugeiconsIcon
+ icon={TextIcon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+ audio: (
+ <HugeiconsIcon
+ icon={MusicNote03Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+ graphic: <OcShapesIcon className="text-muted-foreground size-4 shrink-0" />,
+ effect: (
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+};
export function Timeline() {
- const tracksContainerHeight = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
- const { clearElementSelection, setElementSelection } = useElementSelection();
+ const {
+ selectedElements,
+ clearElementSelection,
+ setElementSelection,
+ mergeElementsIntoSelection,
+ } = useElementSelection();
const editor = useEditor();
const timeline = editor.timeline;
- const tracks = timeline.getTracks();
+ const tracks = useEditor((editor) => editor.timeline.getTracks());
const seek = (time: number) => editor.playback.seek({ time });
const timelineRef = useRef<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
+ const rulerScrollRef = useRef<HTMLDivElement>(null);
const tracksContainerRef = useRef<HTMLDivElement>(null);
const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(null);
@@ -110,8 +153,109 @@ export function Timeline() {
initialScrollLeft: savedViewState?.scrollLeft,
initialPlayheadTime: savedViewState?.playheadTime,
tracksScrollRef,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
+ });
+
+ // Stable refs so the wheel listener never goes stale
+ const setZoomLevelRef = useRef(setZoomLevel);
+ useEffect(() => {
+ setZoomLevelRef.current = setZoomLevel;
+ }, [setZoomLevel]);
+
+ const saveScrollPositionRef = useRef(saveScrollPosition);
+ useEffect(() => {
+ saveScrollPositionRef.current = saveScrollPosition;
+ }, [saveScrollPosition]);
+
+ const minZoomLevelRef = useRef(minZoomLevel);
+ useEffect(() => {
+ minZoomLevelRef.current = minZoomLevel;
+ }, [minZoomLevel]);
+
+ // Pushes tracks scroll position to the two overflow:hidden followers
+ // (ruler and track labels). Called from the wheel handler (before paint,
+ // zero lag) and from onScroll on the tracks area (covers scrollbar drag).
+ const syncFollowers = useCallback(() => {
+ const tracks = tracksScrollRef.current;
+ if (!tracks) return;
+ if (rulerScrollRef.current) {
+ rulerScrollRef.current.scrollLeft = tracks.scrollLeft;
+ }
+ if (trackLabelsScrollRef.current) {
+ trackLabelsScrollRef.current.scrollTop = tracks.scrollTop;
+ }
+ }, []);
+
+ // Single non-passive capture listener owns all wheel input. Prevents any
+ // native scroll or browser zoom from firing inside the timeline.
+ useEffect(() => {
+ const container = timelineRef.current;
+ if (!container) return;
+
+ let pendingZoomDelta = 0;
+ let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null;
+
+ const onWheel = (e: WheelEvent) => {
+ const isZoom = e.ctrlKey || e.metaKey;
+
+ if (isZoom) {
+ e.preventDefault();
+ const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
+ pendingZoomDelta += normalizedDelta;
+
+ if (zoomRafId === null) {
+ zoomRafId = requestAnimationFrame(() => {
+ const frameRawDelta = pendingZoomDelta;
+ const cappedDelta =
+ Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+ setZoomLevelRef.current((prev) => prev * zoomFactor);
+ pendingZoomDelta = 0;
+ zoomRafId = null;
+ });
+ }
+ return;
+ }
+
+ const tracks = tracksScrollRef.current;
+ if (!tracks) return;
+
+ const isHorizontal =
+ e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY);
+
+ e.preventDefault();
+
+ if (isHorizontal) {
+ const raw =
+ Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
+ const clamped =
+ Math.sign(raw) *
+ Math.min(Math.abs(raw), TIMELINE_CONSTANTS.HORIZONTAL_WHEEL_STEP_PX);
+ tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
+ } else {
+ tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
+ }
+
+ syncFollowers();
+ saveScrollPositionRef.current();
+ };
+
+ container.addEventListener("wheel", onWheel, {
+ passive: false,
+ capture: true,
});
+ return () => {
+ container.removeEventListener("wheel", onWheel, { capture: true });
+ if (zoomRafId !== null) cancelAnimationFrame(zoomRafId);
+ };
+ }, [syncFollowers]);
+
+ useInitialScrollBottom({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ onAfterScroll: () => saveScrollPositionRef.current(),
+ isReady: tracks.length > 0,
+ });
const {
dragState,
@@ -124,7 +268,6 @@ export function Timeline() {
timelineRef,
tracksContainerRef,
tracksScrollRef,
- headerRef: timelineHeaderRef,
snappingEnabled,
onSnapPointChange: handleSnapPointChange,
});
@@ -144,14 +287,13 @@ export function Timeline() {
useTimelinePlayhead({
zoomLevel,
rulerRef,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
tracksScrollRef,
playheadRef,
});
const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
containerRef: tracksContainerRef,
- headerRef: timelineHeaderRef,
tracksScrollRef,
zoomLevel,
});
@@ -161,17 +303,37 @@ export function Timeline() {
handleMouseDown: handleSelectionMouseDown,
isSelecting,
shouldIgnoreClick,
- } = useSelectionBox({
+ } = useBoxSelect({
containerRef: tracksContainerRef,
- headerRef: timelineHeaderRef,
- onSelectionComplete: (elements) => {
- setElementSelection({ elements });
+ selectedIds: selectedElements,
+ anchorId: null,
+ getIsAdditiveSelection: (event) =>
+ event.shiftKey || event.ctrlKey || event.metaKey,
+ resolveIntersections: ({ startPos, currentPos }) => {
+ if (!tracksContainerRef.current) {
+ return [];
+ }
+
+ return resolveTimelineElementIntersections({
+ container: tracksContainerRef.current,
+ scrollContainer: tracksScrollRef.current,
+ tracks,
+ zoomLevel,
+ startPos,
+ currentPos,
+ });
+ },
+ onSelectionChange: ({ intersectedIds, isAdditive }) => {
+ if (isAdditive) {
+ mergeElementsIntoSelection({ elements: intersectedIds });
+ } else {
+ setElementSelection({ elements: intersectedIds });
+ }
},
- tracksScrollRef,
- zoomLevel,
});
- const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
+ const containerWidth =
+ tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth =
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const paddingPx = getTimelinePaddingPx({
@@ -183,11 +345,16 @@ export function Timeline() {
contentWidth + paddingPx,
containerWidth,
);
+ const tracksViewportWidth =
+ tracksScrollRef.current?.clientWidth ??
+ tracksContainerRef.current?.clientWidth ??
+ containerWidth;
+ const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth;
useEdgeAutoScroll({
isActive: bookmarkDragState.isDragging,
getMouseClientX: () => bookmarkLastMouseXRef.current,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
tracksScrollRef,
contentWidth: dynamicTimelineWidth,
});
@@ -205,7 +372,7 @@ export function Timeline() {
} = useTimelineSeek({
playheadRef,
trackLabelsRef,
- rulerScrollRef: tracksScrollRef,
+ rulerScrollRef,
tracksScrollRef,
zoomLevel,
duration: timeline.getTotalDuration(),
@@ -214,13 +381,9 @@ export function Timeline() {
seek,
});
- useScrollSync({
- tracksScrollRef,
- trackLabelsScrollRef,
- });
-
const timelineHeaderHeight =
- timelineHeaderRef.current?.getBoundingClientRect().height ?? 0;
+ (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
+ TIMELINE_CONSTANTS.PADDING_TOP_PX || 0;
return (
<section
@@ -236,315 +399,409 @@ export function Timeline() {
setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
/>
- <div
- className="relative flex flex-1 flex-col overflow-hidden"
- ref={timelineRef}
- >
- <SnapIndicator
- snapPoint={currentSnapPoint}
- zoomLevel={zoomLevel}
- tracks={tracks}
- timelineRef={timelineRef}
+ <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}>
+ <TrackLabelsPanel
trackLabelsRef={trackLabelsRef}
- tracksScrollRef={tracksScrollRef}
- isVisible={showSnapIndicator}
+ trackLabelsScrollRef={trackLabelsScrollRef}
+ timelineHeaderHeight={timelineHeaderHeight}
+ hasHorizontalScrollbar={hasHorizontalScrollbar}
/>
- <div className="flex flex-1 overflow-hidden">
- <div className="bg-background flex w-28 shrink-0 flex-col border-r">
- <div className="bg-background flex h-4 items-center justify-between px-3">
- <span className="opacity-0">.</span>
- </div>
- <div className="bg-background flex h-4 items-center justify-between px-3">
- <span className="opacity-0">.</span>
+
+ <div
+ className="relative isolate flex flex-1 flex-col overflow-hidden"
+ ref={tracksContainerRef}
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
+ <DragLine
+ dropTarget={dropTarget}
+ tracks={timeline.getTracks()}
+ isVisible={isDragOver && !dropTarget?.targetElement}
+ headerHeight={timelineHeaderHeight}
+ />
+ <DragLine
+ dropTarget={dragDropTarget}
+ tracks={timeline.getTracks()}
+ isVisible={dragState.isDragging}
+ headerHeight={timelineHeaderHeight}
+ />
+
+ <div ref={rulerScrollRef} className="shrink-0 overflow-hidden">
+ <div
+ ref={timelineHeaderRef}
+ className="flex flex-col"
+ style={{ width: `${dynamicTimelineWidth}px` }}
+ >
+ <TimelineRuler
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ rulerRef={rulerRef}
+ tracksScrollRef={rulerScrollRef}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
+ <TimelineBookmarksRow
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ dragState={bookmarkDragState}
+ onBookmarkMouseDown={handleBookmarkMouseDown}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
</div>
- {tracks.length > 0 && (
- <div
- ref={trackLabelsRef}
- className="bg-background flex-1 overflow-y-auto"
- style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
- >
- <ScrollArea className="size-full" ref={trackLabelsScrollRef}>
- <div className="flex flex-col gap-1">
- {tracks.map((track) => (
- <div
- key={track.id}
- className="group flex items-center px-3"
- style={{
- height: `${getTrackHeight({ type: track.type })}px`,
- }}
- >
- <div className="flex min-w-0 flex-1 items-center justify-end gap-2">
- {process.env.NODE_ENV === "development" &&
- isMainTrack(track) && (
- <div className="bg-red-500 size-1.5 rounded-full" />
- )}
- {canTracktHaveAudio(track) && (
- <TrackToggleIcon
- isOff={track.muted}
- icons={{
- on: VolumeHighIcon,
- off: VolumeOffIcon,
- }}
- onClick={() =>
- editor.timeline.toggleTrackMute({
- trackId: track.id,
- })
- }
- />
- )}
- {canTrackBeHidden(track) && (
- <TrackToggleIcon
- isOff={track.hidden}
- icons={{
- on: ViewIcon,
- off: ViewOffSlashIcon,
- }}
- onClick={() =>
- editor.timeline.toggleTrackVisibility({
- trackId: track.id,
- })
- }
- />
- )}
- <TrackIcon track={track} />
- </div>
- </div>
- ))}
- </div>
- </ScrollArea>
- </div>
- )}
</div>
- <div
- className="relative flex flex-1 flex-col overflow-hidden"
- ref={tracksContainerRef}
+ <ScrollArea
+ className="flex-1"
+ ref={tracksScrollRef}
+ onScroll={() => {
+ syncFollowers();
+ saveScrollPosition();
+ }}
>
- <SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
- />
- <DragLine
- dropTarget={dropTarget}
- tracks={timeline.getTracks()}
- isVisible={isDragOver && !dropTarget?.targetElement}
- headerHeight={timelineHeaderHeight}
- />
- <DragLine
- dropTarget={dragDropTarget}
- tracks={timeline.getTracks()}
- isVisible={dragState.isDragging}
- headerHeight={timelineHeaderHeight}
- />
- <ScrollArea
- className="size-full overflow-y-hidden"
- ref={tracksScrollRef}
- onMouseDown={(event) => {
- const isDirectTarget = event.target === event.currentTarget;
- if (!isDirectTarget) return;
- event.stopPropagation();
- handleTracksMouseDown(event);
- handleSelectionMouseDown(event);
- }}
- onClick={(event) => {
- const isDirectTarget = event.target === event.currentTarget;
- if (!isDirectTarget) return;
- event.stopPropagation();
- handleTracksClick(event);
- }}
- onWheel={(event) => {
- if (
- event.shiftKey ||
- Math.abs(event.deltaX) > Math.abs(event.deltaY)
- ) {
- return;
- }
- handleWheel(event);
- }}
- onScroll={() => {
- saveScrollPosition();
- }}
+ <div
+ className="flex min-h-full flex-col"
+ style={{ width: `${dynamicTimelineWidth}px` }}
>
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
<div
- className="relative"
+ className="relative shrink-0"
style={{
- width: `${dynamicTimelineWidth}px`,
+ height: `${
+ Math.max(
+ TRACKS_CONTAINER_HEIGHT.min,
+ Math.min(
+ TRACKS_CONTAINER_HEIGHT.max,
+ getTotalTracksHeight({ tracks }),
+ ),
+ ) + TIMELINE_CONSTANTS.PADDING_TOP_PX
+ }px`,
+ }}
+ onMouseDown={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksClick(event);
}}
>
- <div
- ref={timelineHeaderRef}
- className="bg-background sticky top-0 flex flex-col"
- >
- <TimelineRuler
+ {tracks.length > 0 && (
+ <TimelineTrackRows
+ dragElementId={dragState.elementId}
zoomLevel={zoomLevel}
- dynamicTimelineWidth={dynamicTimelineWidth}
- rulerRef={rulerRef}
+ dragState={dragState}
tracksScrollRef={tracksScrollRef}
- handleWheel={handleWheel}
- handleTimelineContentClick={handleRulerClick}
- handleRulerTrackingMouseDown={handleRulerMouseDown}
- handleRulerMouseDown={handlePlayheadRulerMouseDown}
- />
- <TimelineBookmarksRow
- zoomLevel={zoomLevel}
- dynamicTimelineWidth={dynamicTimelineWidth}
- dragState={bookmarkDragState}
- onBookmarkMouseDown={handleBookmarkMouseDown}
- handleWheel={handleWheel}
- handleTimelineContentClick={handleRulerClick}
- handleRulerTrackingMouseDown={handleRulerMouseDown}
- handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ lastMouseXRef={lastMouseXRef}
+ onSnapPointChange={handleSnapPointChange}
+ onResizeStateChange={handleResizeStateChange}
+ onElementMouseDown={handleElementMouseDown}
+ onElementClick={handleElementClick}
+ onTrackMouseDown={(event) => {
+ handleSelectionMouseDown(event);
+ handleTracksMouseDown(event);
+ }}
+ onTrackMouseUp={handleTracksClick}
+ shouldIgnoreClick={shouldIgnoreClick}
+ isDragOver={isDragOver}
+ dropTarget={dropTarget}
/>
- </div>
- <TimelinePlayhead
- zoomLevel={zoomLevel}
- rulerRef={rulerRef}
- rulerScrollRef={tracksScrollRef}
- tracksScrollRef={tracksScrollRef}
- timelineRef={timelineRef}
- playheadRef={playheadRef}
- isSnappingToPlayhead={
- showSnapIndicator && currentSnapPoint?.type === "playhead"
- }
- />
+ )}
+ </div>
+ <TimelineGutter
+ onMouseDown={(event) => {
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={handleTracksClick}
+ />
+ </div>
+ </ScrollArea>
+
+ <TimelinePlayhead
+ zoomLevel={zoomLevel}
+ hasHorizontalScrollbar={hasHorizontalScrollbar}
+ rulerRef={rulerRef}
+ rulerScrollRef={rulerScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ timelineRef={timelineRef}
+ playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+ </div>
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ timelineRef={timelineRef}
+ tracksScrollRef={tracksScrollRef}
+ isVisible={showSnapIndicator}
+ tracks={tracks}
+ />
+ </div>
+ </section>
+ );
+}
+
+function TrackLabelsPanel({
+ trackLabelsRef,
+ trackLabelsScrollRef,
+ timelineHeaderHeight,
+ hasHorizontalScrollbar,
+}: {
+ trackLabelsRef: React.RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
+ timelineHeaderHeight: number;
+ hasHorizontalScrollbar: boolean;
+}) {
+ const editor = useEditor();
+ const tracks = useEditor((e) => e.timeline.getTracks());
+ const { selectedElements } = useElementSelection();
+ const tracksWithSelection = useMemo(
+ () => new Set(selectedElements.map((el) => el.trackId)),
+ [selectedElements],
+ );
+
+ return (
+ <div className="flex w-28 shrink-0 flex-col border-r">
+ <div
+ className="shrink-0"
+ style={{ height: timelineHeaderHeight || 48 }}
+ />
+ <div ref={trackLabelsRef} className="flex-1 overflow-hidden">
+ <div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
+ {tracks.length > 0 && (
+ <div className="flex flex-col" style={{ gap: `${TRACK_GAP}px` }}>
+ {tracks.map((track) => (
<div
- className="relative"
+ key={track.id}
+ className={cn(
+ "group flex items-center px-3",
+ tracksWithSelection.has(track.id) &&
+ TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
+ )}
style={{
- height: `${Math.max(
- tracksContainerHeight.min,
- Math.min(
- tracksContainerHeight.max,
- getTotalTracksHeight({ tracks }),
- ),
- )}px`,
+ height: `${getTrackHeight({ type: track.type })}px`,
}}
>
- {tracks.length === 0 ? (
- <div />
- ) : (
- [...tracks]
- .map((track, index) => ({ track, index }))
- .sort((a, b) => {
- const aHasDragged = a.track.elements.some(
- (element) => element.id === dragState.elementId,
- );
- const bHasDragged = b.track.elements.some(
- (element) => element.id === dragState.elementId,
- );
- if (aHasDragged) return 1;
- if (bHasDragged) return -1;
- return 0;
- })
- .map(({ track, index }) => (
- <ContextMenu key={track.id}>
- <ContextMenuTrigger asChild>
- <div
- className="absolute right-0 left-0"
- style={{
- top: `${getCumulativeHeightBefore({
- tracks,
- trackIndex: index,
- })}px`,
- height: `${getTrackHeight({
- type: track.type,
- })}px`,
- }}
- >
- <TimelineTrackContent
- track={track}
- zoomLevel={zoomLevel}
- dragState={dragState}
- rulerScrollRef={tracksScrollRef}
- tracksScrollRef={tracksScrollRef}
- lastMouseXRef={lastMouseXRef}
- onSnapPointChange={handleSnapPointChange}
- onResizeStateChange={handleResizeStateChange}
- onElementMouseDown={handleElementMouseDown}
- onElementClick={handleElementClick}
- onTrackMouseDown={(event) => {
- handleSelectionMouseDown(event);
- handleTracksMouseDown(event);
- }}
- onTrackClick={handleTracksClick}
- shouldIgnoreClick={shouldIgnoreClick}
- targetElementId={
- isDragOver
- ? dropTarget?.targetElement?.elementId ?? null
- : null
- }
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent className="w-40">
- <ContextMenuItem
- icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
- onClick={(event) => {
- event.stopPropagation();
- invokeAction("paste-copied");
- }}
- >
- Paste elements
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(event) => {
- event.stopPropagation();
- timeline.toggleTrackMute({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={VolumeHighIcon} />
- <span>
- {canTracktHaveAudio(track) && track.muted
- ? "Unmute track"
- : "Mute track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(event) => {
- event.stopPropagation();
- timeline.toggleTrackVisibility({
- trackId: track.id,
- });
- }}
- >
- <HugeiconsIcon icon={ViewIcon} />
- <span>
- {canTrackBeHidden(track) && track.hidden
- ? "Show track"
- : "Hide track"}
- </span>
- </ContextMenuItem>
- <ContextMenuItem
- onClick={(event) => {
- event.stopPropagation();
- timeline.removeTrack({
- trackId: track.id,
- });
- }}
- variant="destructive"
- >
- <HugeiconsIcon icon={Delete02Icon} />
- Delete track
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))
- )}
+ <div className="flex min-w-0 flex-1 items-center justify-end gap-2">
+ {canTracktHaveAudio(track) && (
+ <TrackToggleIcon
+ isOff={track.muted}
+ icons={{ on: VolumeHighIcon, off: VolumeOffIcon }}
+ onClick={() =>
+ editor.timeline.toggleTrackMute({ trackId: track.id })
+ }
+ />
+ )}
+ {canTrackBeHidden(track) && (
+ <TrackToggleIcon
+ isOff={track.hidden}
+ icons={{ on: ViewIcon, off: ViewOffSlashIcon }}
+ onClick={() =>
+ editor.timeline.toggleTrackVisibility({
+ trackId: track.id,
+ })
+ }
+ />
+ )}
+ <TrackIcon track={track} />
+ </div>
</div>
- </div>
- </ScrollArea>
- </div>
+ ))}
+ </div>
+ )}
</div>
</div>
- </section>
+ <div
+ className="bg-background shrink-0"
+ style={{
+ height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0,
+ }}
+ />
+ </div>
+ );
+}
+
+function TimelineTrackRows({
+ dragElementId,
+ zoomLevel,
+ dragState,
+ tracksScrollRef,
+ lastMouseXRef,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ onTrackMouseUp,
+ shouldIgnoreClick,
+ isDragOver,
+ dropTarget,
+}: {
+ dragElementId: string | null;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ lastMouseXRef: React.RefObject<number>;
+ onSnapPointChange: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: React.ComponentProps<
+ typeof TimelineTrackContent
+ >["onElementMouseDown"];
+ onElementClick: React.ComponentProps<
+ typeof TimelineTrackContent
+ >["onElementClick"];
+ onTrackMouseDown: (event: React.MouseEvent) => void;
+ onTrackMouseUp: (event: React.MouseEvent) => void;
+ shouldIgnoreClick: () => boolean;
+ isDragOver: boolean;
+ dropTarget: DropTarget | null;
+}) {
+ const timeline = useEditor((e) => e.timeline);
+ const tracks = useEditor((e) => e.timeline.getTracks());
+ const { selectedElements } = useElementSelection();
+ const tracksWithSelection = useMemo(
+ () => new Set(selectedElements.map((el) => el.trackId)),
+ [selectedElements],
+ );
+
+ const sortedTracks = useMemo(
+ () =>
+ [...tracks]
+ .map((track, index) => ({ track, index }))
+ .sort((a, b) => {
+ const aHasDragged = a.track.elements.some(
+ (el) => el.id === dragElementId,
+ );
+ const bHasDragged = b.track.elements.some(
+ (el) => el.id === dragElementId,
+ );
+ if (aHasDragged) return 1;
+ if (bHasDragged) return -1;
+ return 0;
+ }),
+ [tracks, dragElementId],
);
+
+ return (
+ <>
+ {sortedTracks.map(({ track, index }) => (
+ <ContextMenu key={track.id}>
+ <ContextMenuTrigger asChild>
+ <div
+ className={cn(
+ "absolute right-0 left-0 transition-colors",
+ tracksWithSelection.has(track.id) &&
+ TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
+ )}
+ style={{
+ top: `${TIMELINE_CONSTANTS.PADDING_TOP_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
+ height: `${getTrackHeight({ type: track.type })}px`,
+ }}
+ >
+ <TimelineTrackContent
+ track={track}
+ zoomLevel={zoomLevel}
+ dragState={dragState}
+ rulerScrollRef={tracksScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ lastMouseXRef={lastMouseXRef}
+ onSnapPointChange={onSnapPointChange}
+ onResizeStateChange={onResizeStateChange}
+ onElementMouseDown={onElementMouseDown}
+ onElementClick={onElementClick}
+ onTrackMouseDown={onTrackMouseDown}
+ onTrackMouseUp={onTrackMouseUp}
+ shouldIgnoreClick={shouldIgnoreClick}
+ targetElementId={
+ isDragOver
+ ? (dropTarget?.targetElement?.elementId ?? null)
+ : null
+ }
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-40">
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.toggleTrackMute({ trackId: track.id });
+ }}
+ >
+ {canTracktHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={ViewIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.toggleTrackVisibility({ trackId: track.id });
+ }}
+ >
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+ </ContextMenuItem>
+ {!isMainTrack(track) && (
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.removeTrack({ trackId: track.id });
+ }}
+ variant="destructive"
+ >
+ Delete track
+ </ContextMenuItem>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+ </>
+ );
+}
+
+function TimelineGutter({
+ onMouseDown,
+ onClick,
+}: {
+ onMouseDown: (event: React.MouseEvent) => void;
+ onClick: (event: React.MouseEvent) => void;
+}) {
+ // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system
+ // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system
+ return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />;
}
function TrackIcon({ track }: { track: TimelineTrack }) {
- return <>{TRACK_CONFIG[track.type].icon}</>;
+ return <>{TRACK_ICONS[track.type]}</>;
}
function TrackToggleIcon({
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
index 927d336a..acb57203 100644
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -6,15 +6,12 @@ import {
getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX,
} from "@/lib/timeline";
-import type { TimelineTrack } from "@/types/timeline";
-
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
interface SnapIndicatorProps {
snapPoint: SnapPoint | null;
zoomLevel: number;
isVisible: boolean;
- tracks: TimelineTrack[];
timelineRef: React.RefObject<HTMLDivElement | null>;
- trackLabelsRef?: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
}
@@ -22,17 +19,13 @@ export function SnapIndicator({
snapPoint,
zoomLevel,
isVisible,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
}: SnapIndicatorProps) {
const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
snapPoint,
zoomLevel,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
});
@@ -48,6 +41,7 @@ export function SnapIndicator({
top: topPosition,
height: `${height}px`,
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.snapIndicator,
}}
>
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 9121f6c2..13a31375 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -2,7 +2,7 @@
import { useEditor } from "@/hooks/use-editor";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
-import AudioWaveform from "./audio-waveform";
+import { AudioWaveform } from "./audio-waveform";
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
import {
useKeyframeDrag,
@@ -12,14 +12,16 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele
import type { SnapPoint } from "@/lib/timeline/snap-utils";
import { getElementKeyframes } from "@/lib/animation";
import {
- getTrackClasses,
+ getElementClasses,
getTrackHeight,
canElementHaveAudio,
canElementBeHidden,
+ hasElementEffects,
hasMediaId,
timelineTimeToPixels,
timelineTimeToSnappedPixels,
} from "@/lib/timeline";
+import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
import {
ContextMenu,
ContextMenuContent,
@@ -30,14 +32,22 @@ import {
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
- VisualElement,
ElementDragState,
-} from "@/types/timeline";
-import type { MediaAsset } from "@/types/assets";
+ VideoElement,
+ ImageElement,
+ AudioElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
-import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
+import {
+ getActionDefinition,
+ type TAction,
+ type TActionWithOptionalArgs,
+ invokeAction,
+} from "@/lib/actions";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { resolveStickerId } from "@/lib/stickers";
+import { buildGraphicPreviewUrl } from "@/lib/graphics";
import Image from "next/image";
import {
ScissorIcon,
@@ -56,13 +66,16 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { uppercase } from "@/utils/string";
import type { ComponentProps, ReactNode } from "react";
-import type { SelectedKeyframeRef, ElementKeyframe } from "@/types/animation";
+import type {
+ SelectedKeyframeRef,
+ ElementKeyframe,
+} from "@/lib/animation/types";
import { cn } from "@/utils/ui";
-import { Button } from "@/components/ui/button";
-import { usePropertiesStore } from "@/stores/properties-store";
+import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
const ELEMENT_RING_WIDTH_PX = 1.5;
+const THUMBNAIL_ASPECT_RATIO = 16 / 9;
interface KeyframeIndicator {
time: number;
@@ -195,16 +208,15 @@ export function TimelineElement({
dragState,
isDropTarget = false,
}: TimelineElementProps) {
- const editor = useEditor();
+ const mediaAssets = useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection();
- const { requestRevealMedia } = useAssetsPanelStore();
+ const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
let mediaAsset: MediaAsset | null = null;
if (hasMediaId(element)) {
mediaAsset =
- editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
- null;
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
}
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
@@ -394,60 +406,46 @@ function ElementInner({
}) => void;
isDropTarget?: boolean;
}) {
- const opacityClass =
- (canElementBeHidden(element) && element.hidden) || isDropTarget
- ? "opacity-50"
- : "";
- const closeClipEffects = usePropertiesStore(
- (state) => state.closeClipEffects,
- );
-
+ const isReducedOpacity =
+ (canElementBeHidden(element) && element.hidden) || isDropTarget;
return (
<div
- className="relative h-full cursor-pointer"
- style={{ marginInline: ELEMENT_RING_WIDTH_PX }}
+ className="absolute top-0 bottom-0"
+ style={{
+ left: `${ELEMENT_RING_WIDTH_PX}px`,
+ right: `${ELEMENT_RING_WIDTH_PX}px`,
+ }}
>
<div
- className={cn(
- "absolute inset-0 overflow-hidden rounded-sm",
- getTrackClasses({ type: track.type }),
- opacityClass,
- )}
+ className="absolute inset-0 rounded-sm"
style={
isSelected
? {
- boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--foreground)`,
+ boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`,
}
: undefined
}
>
- <button
- type="button"
- className="absolute inset-0 size-full cursor-pointer flex flex-col"
- onClick={(event) => {
- closeClipEffects();
- onElementClick(event, element);
- }}
- onMouseDown={(event) => onElementMouseDown(event, element)}
+ <div
+ className={cn(
+ "absolute inset-0 overflow-hidden rounded-sm",
+ getElementClasses({ type: track.type }),
+ isReducedOpacity && "opacity-50",
+ )}
>
- <div className="flex flex-1 min-h-0 items-center overflow-hidden">
- <ElementContent
- element={element}
- track={track}
- isSelected={isSelected}
- />
- </div>
- </button>
- </div>
-
- {element.type !== "audio" && element.type !== "effect" && (
- <div className="sticky left-1 mt-1 ml-1 w-fit">
- <EffectsButton
- element={element as VisualElement}
- trackId={track.id}
- />
+ <button
+ type="button"
+ tabIndex={-1}
+ className="absolute inset-0 size-full flex flex-col"
+ onClick={(event) => onElementClick(event, element)}
+ onMouseDown={(event) => onElementMouseDown(event, element)}
+ >
+ <div className="flex flex-1 min-h-0 items-center overflow-hidden">
+ <ElementContent element={element} track={track} />
+ </div>
+ </button>
</div>
- )}
+ </div>
{isSelected && (
<>
@@ -535,8 +533,8 @@ function KeyframeIndicators({
const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
isKeyframeSelected({ keyframe }),
);
- const isBeingDragged = indicator.keyframes.some((kf) =>
- dragState.draggingKeyframeIds.has(kf.keyframeId),
+ const isBeingDragged = indicator.keyframes.some((keyframe) =>
+ dragState.draggingKeyframeIds.has(keyframe.keyframeId),
);
const visualOffsetPx = getVisualOffsetPx({
indicatorTime: indicator.time,
@@ -581,212 +579,244 @@ function KeyframeIndicators({
interface ElementContentProps {
element: TimelineElementType;
track: TimelineTrack;
- isSelected: boolean;
}
-interface ElementContentRendererProps extends ElementContentProps {
- mediaAssets: MediaAsset[];
+function TextElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "text" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start pl-2">
+ <span className="truncate text-xs text-white">{element.content}</span>
+ </div>
+ );
+}
+
+function EffectElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "effect" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start gap-1 pl-2">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-4 shrink-0 text-white"
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
}
-type ElementContentRenderer = (props: ElementContentRendererProps) => ReactNode;
+function StickerElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "sticker" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={resolveStickerId({
+ stickerId: element.stickerId,
+ options: { width: 20, height: 20 },
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
-export function renderTiledMedia({
+function GraphicElementContent({
element,
- imageUrl,
- track,
}: {
- element: VisualElement;
- imageUrl: string | undefined;
- track: ElementContentProps["track"];
-}): ReactNode {
- if (!imageUrl) {
+ element: Extract<TimelineElementType, { type: "graphic" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={buildGraphicPreviewUrl({
+ definitionId: element.definitionId,
+ params: element.params,
+ size: 20,
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function AudioElementContent({ element }: { element: AudioElement }) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const mediaAsset =
+ element.sourceType === "upload"
+ ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null)
+ : null;
+
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+ const audioUrl =
+ element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
+ const mediaLabel = mediaAsset?.name ?? element.name;
+
+ if (audioBuffer || audioUrl) {
return (
- <span className="text-foreground/80 truncate text-xs">
- {element.name}
- </span>
+ <div className="relative size-full">
+ <AudioWaveform
+ audioBuffer={audioBuffer}
+ audioUrl={audioUrl}
+ color={ELEMENT_TYPE_CONFIG.audio.waveformColor}
+ />
+ <MediaElementHeader name={mediaLabel} hasFade={false} />
+ </div>
);
}
- const trackHeight = getTrackHeight({ type: track.type });
- const tileWidth = trackHeight * (16 / 9);
-
return (
- <div
- className="absolute inset-0"
- style={{
- backgroundImage: `url(${imageUrl})`,
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${trackHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- />
+ <span className="text-foreground/80 truncate text-xs">{element.name}</span>
);
}
function EffectsButton({
element,
- trackId,
- className,
+ track,
}: {
- element: VisualElement;
- trackId: string;
- className?: string;
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
}) {
- const openClipEffects = usePropertiesStore((state) => state.openClipEffects);
- const { selectElement } = useElementSelection();
-
- if (!element.effects?.length) {
- return null;
- }
+ const editor = useEditor();
+ const setActiveTab = usePropertiesStore((s) => s.setActiveTab);
const handleClick = (event: React.MouseEvent) => {
event.stopPropagation();
- selectElement({ elementId: element.id, trackId });
- openClipEffects({ elementId: element.id, trackId });
+ editor.selection.setSelectedElements({
+ elements: [{ trackId: track.id, elementId: element.id }],
+ });
+ setActiveTab(element.type, "effects");
};
return (
- <Button
- variant="text"
- size="icon"
- className={cn("rounded-sm !size-5 bg-black/50 text-white", className)}
- onClick={handleClick}
+ <button
+ type="button"
+ className="flex shrink-0 justify-center text-white cursor-pointer"
onMouseDown={(event) => event.stopPropagation()}
+ onClick={handleClick}
>
- <HugeiconsIcon icon={MagicWand05Icon} />
- </Button>
+ <HugeiconsIcon icon={MagicWand05Icon} size={12} />
+ </button>
);
}
-const ELEMENT_CONTENT_RENDERERS: Record<
- TimelineElementType["type"],
- ElementContentRenderer
-> = {
- text: ({ element }) => {
- const textElement = element as Extract<
- TimelineElementType,
- { type: "text" }
- >;
- return (
- <div className="flex size-full items-center justify-start pl-2">
- <span className="truncate text-xs text-white">
- {textElement.content}
- </span>
- </div>
- );
- },
- effect: ({ element }) => (
- <div className="flex size-full items-center justify-start gap-1 pl-2">
- <HugeiconsIcon
- icon={MagicWand05Icon}
- className="size-4 shrink-0 text-white"
- />
- <span className="truncate text-xs text-white ml-1">{element.name}</span>
- </div>
- ),
- sticker: ({ element }) => {
- const stickerElement = element as Extract<
- TimelineElementType,
- { type: "sticker" }
- >;
- return (
- <div className="flex size-full items-center gap-2 pl-2">
- <Image
- src={resolveStickerId({
- stickerId: stickerElement.stickerId,
- options: { width: 20, height: 20 },
- })}
- alt={stickerElement.name}
- className="size-5 shrink-0"
- width={20}
- height={20}
- unoptimized
- />
- <span className="truncate text-xs text-white">
- {stickerElement.name}
- </span>
- </div>
- );
- },
- audio: ({ element, mediaAssets }) => {
- const audioElement = element as Extract<
- TimelineElementType,
- { type: "audio" }
- >;
- const audioBuffer =
- audioElement.sourceType === "library" ? audioElement.buffer : undefined;
- const audioUrl =
- audioElement.sourceType === "library"
- ? audioElement.sourceUrl
- : mediaAssets.find((asset) => asset.id === audioElement.mediaId)?.url;
+function TiledMediaContent({
+ element,
+ track,
+}: {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
- if (audioBuffer || audioUrl) {
- return (
- <div className="flex size-full items-center gap-2">
- <div className="min-w-0 flex-1">
- <AudioWaveform
- audioBuffer={audioBuffer}
- audioUrl={audioUrl}
- height={24}
- className="w-full"
- />
- </div>
- </div>
- );
- }
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ const imageUrl =
+ element.type === "video"
+ ? mediaAsset?.thumbnailUrl
+ : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url);
+ if (!imageUrl) {
return (
<span className="text-foreground/80 truncate text-xs">
- {audioElement.name}
+ {element.name}
</span>
);
- },
- video: ({ element, track, mediaAssets }) => {
- const videoElement = element as Extract<
- TimelineElementType,
- { type: "video" }
- >;
- const mediaAsset = mediaAssets.find(
- (asset) => asset.id === videoElement.mediaId,
- );
- return renderTiledMedia({
- element: videoElement,
- imageUrl: mediaAsset?.thumbnailUrl,
- track,
- });
- },
- image: ({ element, track, mediaAssets }) => {
- const imageElement = element as Extract<
- TimelineElementType,
- { type: "image" }
- >;
- const mediaAsset = mediaAssets.find(
- (asset) => asset.id === imageElement.mediaId,
- );
- return renderTiledMedia({
- element: imageElement,
- imageUrl: mediaAsset?.url,
- track,
- });
- },
-};
+ }
+
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
-function ElementContent({ element, track, isSelected }: ElementContentProps) {
- const editor = useEditor();
- const renderer = ELEMENT_CONTENT_RENDERERS[element.type];
return (
<>
- {renderer({
- element,
- track,
- isSelected,
- mediaAssets: editor.media.getAssets(),
- })}
+ <div
+ className="absolute inset-0"
+ style={{
+ backgroundColor: "var(--muted)",
+ backgroundImage: `url(${imageUrl})`,
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ />
+ <MediaElementHeader
+ name={mediaAsset?.name}
+ leading={
+ hasElementEffects({ element }) ? (
+ <EffectsButton element={element} track={track} />
+ ) : null
+ }
+ hasFade={true}
+ />
</>
);
}
+function MediaElementHeader({
+ name,
+ leading,
+ hasFade,
+}: {
+ name?: string | null;
+ leading?: ReactNode;
+ hasFade?: boolean;
+}) {
+ if (!name && !leading) {
+ return null;
+ }
+
+ return (
+ <div
+ className={cn(
+ "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1",
+ hasFade && "from-black/30 to-transparent",
+ )}
+ >
+ {leading && <div className="pl-1">{leading}</div>}
+ {name && (
+ <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75">
+ {name}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function ElementContent({ element, track }: ElementContentProps) {
+ switch (element.type) {
+ case "text":
+ return <TextElementContent element={element} />;
+ case "effect":
+ return <EffectElementContent element={element} />;
+ case "sticker":
+ return <StickerElementContent element={element} />;
+ case "graphic":
+ return <GraphicElementContent element={element} />;
+ case "audio":
+ return <AudioElementContent element={element} />;
+ case "video":
+ case "image":
+ return <TiledMediaContent element={element} track={track} />;
+ }
+}
+
function CopyMenuItem() {
return (
<ActionMenuItem
@@ -886,7 +916,7 @@ function ActionMenuItem({
children,
...props
}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
- action: TAction;
+ action: TActionWithOptionalArgs;
children: ReactNode;
}) {
return (
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
index e290259e..1abe65bd 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -8,9 +8,14 @@ import {
} from "@/lib/timeline";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
import { useEditor } from "@/hooks/use-editor";
+import {
+ TIMELINE_LAYERS,
+ TIMELINE_SCROLLBAR_SIZE_PX,
+} from "@/constants/timeline-constants";
interface TimelinePlayheadProps {
zoomLevel: number;
+ hasHorizontalScrollbar: boolean;
rulerRef: React.RefObject<HTMLDivElement | null>;
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
@@ -21,6 +26,7 @@ interface TimelinePlayheadProps {
export function TimelinePlayhead({
zoomLevel,
+ hasHorizontalScrollbar,
rulerRef,
rulerScrollRef,
tracksScrollRef,
@@ -33,7 +39,7 @@ export function TimelinePlayhead({
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ const { handlePlayheadMouseDown } = useTimelinePlayhead({
zoomLevel,
rulerRef,
rulerScrollRef,
@@ -42,16 +48,23 @@ export function TimelinePlayhead({
});
const timelineContainerHeight =
- tracksScrollRef.current?.clientHeight ??
timelineRef.current?.clientHeight ??
+ tracksScrollRef.current?.clientHeight ??
400;
- const totalHeight = Math.max(0, timelineContainerHeight - 4);
+ const totalHeight = Math.max(
+ 0,
+ timelineContainerHeight -
+ (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0),
+ );
+ const currentTime = editor.playback.getCurrentTime();
const centerPosition = timelineTimeToSnappedPixels({
- time: playheadPosition,
+ time: currentTime,
zoomLevel,
});
- const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
+ const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
+ const leftPosition =
+ getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft;
const handlePlayheadKeyDown = (
event: React.KeyboardEvent<HTMLDivElement>,
@@ -61,10 +74,8 @@ export function TimelinePlayhead({
event.preventDefault();
const step = 1 / Math.max(1, editor.project.getActive().settings.fps);
const direction = event.key === "ArrowRight" ? 1 : -1;
- const nextTime = Math.max(
- 0,
- Math.min(duration, playheadPosition + direction * step),
- );
+ const now = editor.playback.getCurrentTime();
+ const nextTime = Math.max(0, Math.min(duration, now + direction * step));
editor.playback.seek({ time: nextTime });
};
@@ -76,23 +87,24 @@ export function TimelinePlayhead({
aria-label="Timeline playhead"
aria-valuemin={0}
aria-valuemax={duration}
- aria-valuenow={playheadPosition}
+ aria-valuenow={currentTime}
tabIndex={0}
- className="pointer-events-none absolute z-5"
+ className="pointer-events-none absolute"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.playhead,
}}
onKeyDown={handlePlayheadKeyDown}
>
- <div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" />
+ <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" />
<button
type="button"
aria-label="Drag playhead"
- className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
+ className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`}
onMouseDown={handlePlayheadMouseDown}
/>
</div>
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
index b0261aa2..09562f23 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx
@@ -1,5 +1,8 @@
import { type JSX, useLayoutEffect, useRef } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ TIMELINE_CONSTANTS,
+ TIMELINE_RULER_HEIGHT,
+} from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
@@ -27,13 +30,12 @@ export function TimelineRuler({
handleRulerTrackingMouseDown,
handleRulerMouseDown,
}: TimelineRulerProps) {
- const editor = useEditor();
- const duration = editor.timeline.getTotalDuration();
+ const duration = useEditor((e) => e.timeline.getTotalDuration());
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
const effectiveDuration = Math.max(duration, visibleDuration);
- const project = editor.project.getActive();
- const fps = project?.settings.fps ?? DEFAULT_FPS;
+ const fps =
+ useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
zoomLevel,
fps,
@@ -104,17 +106,25 @@ export function TimelineRuler({
aria-valuemin={0}
aria-valuemax={effectiveDuration}
aria-valuenow={0}
- className="relative h-4 flex-1 overflow-x-visible"
+ className="relative flex-1 overflow-x-visible"
+ style={{ height: TIMELINE_RULER_HEIGHT }}
onWheel={handleWheel}
- onClick={handleTimelineContentClick}
+ onClick={(event) => {
+ // Ruler seek already happens on mousedown via playhead scrubbing.
+ // Forwarding the follow-up click re-enters the selection-clearing path.
+ if (event.target === event.currentTarget) {
+ handleTimelineContentClick(event);
+ }
+ }}
onMouseDown={handleRulerTrackingMouseDown}
onKeyDown={() => {}}
>
<div
role="none"
ref={rulerRef}
- className="relative h-4 cursor-default select-none"
+ className="relative cursor-default select-none"
style={{
+ height: TIMELINE_RULER_HEIGHT,
width: `${dynamicTimelineWidth}px`,
}}
onMouseDown={handleRulerMouseDown}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
index 22cd52cf..29fd513b 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx
@@ -22,7 +22,7 @@ export function TimelineTick({
const label = formatRulerLabel({ timeInSeconds: time, fps });
return (
<span
- className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none"
+ className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
style={{ left: `${leftPosition}px` }}
>
{label}
@@ -32,7 +32,7 @@ export function TimelineTick({
return (
<div
- className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l"
+ className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
style={{ left: `${leftPosition}px` }}
/>
);
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
index 533ea63d..e785d147 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -16,8 +16,8 @@ import {
import { Slider } from "@/components/ui/slider";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
-import { ScenesView } from "../../scenes-view";
-import { type TAction, invokeAction } from "@/lib/actions";
+import { ScenesView } from "@/components/editor/scenes-view";
+import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
import { cn } from "@/utils/ui";
import { useTimelineStore } from "@/stores/timeline-store";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -76,15 +76,15 @@ export function TimelineToolbar({
}
function ToolbarLeftSection() {
- const editor = useEditor();
- const currentTime = editor.playback.getCurrentTime();
- const isCurrentlyBookmarked = editor.scenes.isBookmarked({ time: currentTime });
+ const isCurrentlyBookmarked = useEditor((e) =>
+ e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
+ );
const handleAction = ({
action,
event,
}: {
- action: TAction;
+ action: TActionWithOptionalArgs;
event: React.MouseEvent;
}) => {
event.stopPropagation();
@@ -149,8 +149,8 @@ function ToolbarLeftSection() {
<Tooltip>
<ToolbarButton
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
- isActive={isCurrentlyBookmarked}
- tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
+ isActive={isCurrentlyBookmarked}
+ tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
onClick={({ event }) =>
handleAction({ action: "toggle-bookmark", event })
}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
index 81cda7a6..6a9e4dfe 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx
@@ -2,12 +2,12 @@
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { TimelineElement } from "./timeline-element";
-import type { TimelineTrack } from "@/types/timeline";
-import type { TimelineElement as TimelineElementType } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
+import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
import type { SnapPoint } from "@/lib/timeline/snap-utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
-import type { ElementDragState } from "@/types/timeline";
+import type { ElementDragState } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
interface TimelineTrackContentProps {
@@ -30,7 +30,7 @@ interface TimelineTrackContentProps {
track: TimelineTrack;
}) => void;
onTrackMouseDown?: (event: React.MouseEvent) => void;
- onTrackClick?: (event: React.MouseEvent) => void;
+ onTrackMouseUp?: (event: React.MouseEvent) => void;
shouldIgnoreClick?: () => boolean;
targetElementId?: string | null;
}
@@ -47,14 +47,12 @@ export function TimelineTrackContent({
onElementMouseDown,
onElementClick,
onTrackMouseDown,
- onTrackClick,
+ onTrackMouseUp,
shouldIgnoreClick,
targetElementId = null,
}: TimelineTrackContentProps) {
- const editor = useEditor();
const { isElementSelected } = useElementSelection();
-
- const duration = editor.timeline.getTotalDuration();
+ const duration = useEditor((e) => e.timeline.getTotalDuration());
useEdgeAutoScroll({
isActive: dragState.isDragging,
@@ -65,21 +63,37 @@ export function TimelineTrackContent({
});
return (
- <button
- className="size-full"
- onClick={(event) => {
- if (shouldIgnoreClick?.()) return;
- onTrackClick?.(event);
- }}
- onMouseDown={(event) => {
- event.preventDefault();
- onTrackMouseDown?.(event);
- }}
- type="button"
- >
- <div className="relative h-full min-w-full">
+ <div className="relative size-full">
+ <button
+ type="button"
+ className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
+ aria-label={`Select ${track.name} track`}
+ onMouseUp={(event) => {
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ />
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
+ <div
+ className="relative h-full min-w-full"
+ style={{ zIndex: TIMELINE_LAYERS.trackContent }}
+ onMouseUp={(event) => {
+ if (event.target !== event.currentTarget) return;
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ >
{track.elements.length === 0 ? (
- <div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
+ <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
) : (
track.elements.map((element) => {
const isSelected = isElementSelected({
@@ -109,6 +123,6 @@ export function TimelineTrackContent({
})
)}
</div>
- </button>
+ </div>
);
}
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index 746cfd16..35e5dfba 100644
--- a/apps/web/src/components/providers/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
@@ -3,13 +3,12 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
+import { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
-import {
- useKeybindingsListener,
- useKeybindingDisabler,
-} from "@/hooks/use-keybindings";
+import { useKeybindingsListener } from "@/hooks/use-keybindings";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
-import { prefetchFontAtlas } from "@/lib/fonts/google-fonts";
+import { loadFontAtlas } from "@/lib/fonts/google-fonts";
interface EditorProviderProps {
projectId: string;
@@ -17,23 +16,19 @@ interface EditorProviderProps {
}
export function EditorProvider({ projectId, children }: EditorProviderProps) {
- const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActiveOrNull());
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
- const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
- const activeProject = editor.project.getActiveOrNull();
+ const { setLoadingProject } = useKeybindingsStore();
useEffect(() => {
- if (isLoading) {
- disableKeybindings();
- } else {
- enableKeybindings();
- }
- }, [isLoading, disableKeybindings, enableKeybindings]);
+ setLoadingProject(isLoading);
+ }, [isLoading, setLoadingProject]);
useEffect(() => {
let cancelled = false;
+ const editor = EditorCore.getInstance();
const loadProject = async () => {
try {
@@ -43,7 +38,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
if (cancelled) return;
setIsLoading(false);
- prefetchFontAtlas();
+ loadFontAtlas();
} catch (err) {
if (cancelled) return;
@@ -76,7 +71,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
return () => {
cancelled = true;
};
- }, [projectId, editor, router]);
+ }, [projectId, router]);
if (error) {
return (
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/section.tsx
index 5989108b..e3fc96df 100644
--- a/apps/web/src/components/editor/panels/properties/section.tsx
+++ b/apps/web/src/components/section.tsx
@@ -36,7 +36,7 @@ export function Section({
defaultOpen = true,
sectionKey,
className,
- showTopBorder = true,
+ showTopBorder = false,
showBottomBorder = true,
}: SectionProps) {
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
@@ -44,11 +44,18 @@ export function Section({
useEffect(() => {
if (!sectionKey) return;
- if (process.env.NODE_ENV !== "production" && mountedSectionKeys.has(sectionKey)) {
- console.error(`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`);
+ if (
+ process.env.NODE_ENV !== "production" &&
+ mountedSectionKeys.has(sectionKey)
+ ) {
+ console.error(
+ `[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`,
+ );
}
mountedSectionKeys.add(sectionKey);
- return () => { mountedSectionKeys.delete(sectionKey); };
+ return () => {
+ mountedSectionKeys.delete(sectionKey);
+ };
}, [sectionKey]);
const toggle = () => {
@@ -62,8 +69,8 @@ export function Section({
<div
className={cn(
"flex flex-col",
- showTopBorder && "border-t",
- showBottomBorder && "last:border-b",
+ showTopBorder && "border-t first:border-t-0",
+ showBottomBorder && "border-b",
className,
)}
>
@@ -96,63 +103,58 @@ export function SectionHeader({
const isInteractive = isCollapsible || !!onClick;
const handleClick = isCollapsible ? ctx?.toggle : onClick;
- const chevronIcon = isCollapsible ? (
+ const chevronIcon = (
<HugeiconsIcon
icon={ArrowDownIcon}
className={cn(
"size-4 shrink-0 transition-transform duration-200 ease-out",
- isOpen ? "rotate-0 text-foreground" : "-rotate-90 text-muted-foreground",
+ isOpen
+ ? "rotate-0 text-foreground"
+ : "-rotate-90 text-muted-foreground",
)}
/>
- ) : null;
+ );
+
+ const trailingArea =
+ trailing || isCollapsible ? (
+ <div className="flex items-center">
+ {trailing}
+ {isCollapsible && (
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={isOpen ? "Collapse section" : "Expand section"}
+ onClick={handleClick}
+ >
+ {chevronIcon}
+ </Button>
+ )}
+ </div>
+ ) : null;
- const headerContent = (
+ const innerContent = isInteractive ? (
+ <button
+ type="button"
+ className="min-w-0 flex-1 flex items-center gap-2 h-full cursor-pointer text-left"
+ onClick={handleClick}
+ >
+ {leading}
+ <div className="min-w-0 flex-1 flex items-center">{children}</div>
+ </button>
+ ) : (
<>
{leading}
- <div className="min-w-0 flex-1">{children}</div>
- {(trailing || chevronIcon) && (
- <div className="flex items-center">
- {trailing}
- {chevronIcon && (
- <Button
- variant="ghost"
- size="icon"
- aria-label={isOpen ? "Collapse section" : "Expand section"}
- onClick={(event) => {
- event.stopPropagation();
- handleClick?.();
- }}
- >
- {chevronIcon}
- </Button>
- )}
- </div>
- )}
- {actions}
+ <div className="min-w-0 flex-1 flex items-center">{children}</div>
</>
);
- if (!isInteractive) {
- return (
- <div className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}>
- {headerContent}
- </div>
- );
- }
-
return (
- // biome-ignore lint/a11y/useSemanticElements: outer div intentionally wraps a nested <Button> (chevron), making <button> invalid HTML here
<div
- role="button"
- tabIndex={0}
- className={cn(
- "flex h-11 w-full cursor-pointer items-center gap-2 px-3.5",
- className,
- )}
- onClick={handleClick}
- onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") handleClick?.(); }}
+ className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}
>
- {headerContent}
+ {innerContent}
+ {trailingArea}
+ {actions}
</div>
);
}
@@ -167,17 +169,20 @@ export function SectionTitle({
onClick?: () => void;
}) {
const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
+ const titleClass = cn(
+ "text-sm font-medium",
+ isCollapsible && isOpen ? "text-foreground" : "text-muted-foreground",
+ className,
+ );
+
if (onClick) {
return (
<button
type="button"
- className={cn(
- "cursor-pointer text-sm font-medium",
- isOpen ? "text-foreground" : "text-muted-foreground",
- className,
- )}
+ className={cn("cursor-pointer", titleClass)}
onClick={onClick}
>
{children}
@@ -185,17 +190,7 @@ export function SectionTitle({
);
}
- return (
- <span
- className={cn(
- "text-sm font-medium",
- isOpen ? "text-foreground" : "text-muted-foreground",
- className,
- )}
- >
- {children}
- </span>
- );
+ return <span className={titleClass}>{children}</span>;
}
export function SectionFields({
@@ -243,6 +238,8 @@ export function SectionContent({
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
+ const content = <div className={cn("p-4 pt-0", className)}>{children}</div>;
+
if (isCollapsible) {
return (
<div
@@ -251,12 +248,10 @@ export function SectionContent({
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
- <div className="overflow-hidden">
- <div className={cn("p-4 pt-0", className)}>{children}</div>
- </div>
+ <div className="overflow-hidden">{content}</div>
</div>
);
}
- return <div className={cn("p-4 pt-0", className)}>{children}</div>;
+ return content;
}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index aa52e228..3dff9417 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -23,7 +23,7 @@ const buttonVariants = cva(
},
size: {
default: "h-9 px-4 py-2",
- sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm",
+ sm: "h-7 p-1 px-2.5 text-sm rounded-sm",
lg: "h-10 p-5 px-6",
icon: "size-7 rounded-sm",
text: "p-0",
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx
index fbf9bbd7..6d911a75 100644
--- a/apps/web/src/components/ui/color-picker.tsx
+++ b/apps/web/src/components/ui/color-picker.tsx
@@ -270,6 +270,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
backgroundColor: "#fff",
};
+
return (
<Popover>
<div
@@ -285,10 +286,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
type="button"
>
- <span
- className="absolute inset-0"
- style={checkerboardStyle}
- />
+ <span
+ className="absolute inset-0 dark:invert"
+ style={checkerboardStyle}
+ />
<span
className="absolute inset-0"
style={{ backgroundColor: `#${value}` }}
@@ -298,7 +299,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
<div className="flex flex-1 items-center">
<Input
className={cn(
- "!border-0 bg-transparent p-0 !ring-0 !ring-offset-0",
+ "border-0! bg-transparent p-0 ring-0! ring-offset-0!",
colorFormat === "hex" && "uppercase",
)}
size="sm"
@@ -387,10 +388,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
<button
ref={opacityRef}
className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0"
- style={checkerboardStyle}
type="button"
onMouseDown={handleOpacityMouseDown}
>
+ <div className="absolute inset-0 dark:invert" style={checkerboardStyle} />
<div
className="absolute inset-0 rounded-lg"
style={{
diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx
index d8791bdf..ec02de6b 100644
--- a/apps/web/src/components/ui/context-menu.tsx
+++ b/apps/web/src/components/ui/context-menu.tsx
@@ -10,8 +10,20 @@ import {
ArrowRightIcon,
CircleIcon,
} from "@hugeicons/core-free-icons";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const ContextMenu = ContextMenuPrimitive.Root;
+function ContextMenu({
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "context-menu",
+ onOpenChange,
+ });
+ return (
+ <ContextMenuPrimitive.Root onOpenChange={handleOpenChange} {...props} />
+ );
+}
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index d124a2e9..f5045f95 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -4,8 +4,22 @@ import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { X } from "lucide-react";
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Dialog = DialogPrimitive.Root;
+function Dialog({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "dialog",
+ open,
+ onOpenChange,
+ });
+ return (
+ <DialogPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
const DialogTrigger = DialogPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index 2d6541b8..91913e25 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -4,10 +4,27 @@ import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
-
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const DropdownMenu = DropdownMenuPrimitive.Root;
+function DropdownMenu({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "dropdown-menu",
+ open,
+ onOpenChange,
+ });
+ return (
+ <DropdownMenuPrimitive.Root
+ open={open}
+ onOpenChange={handleOpenChange}
+ {...props}
+ />
+ );
+}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
@@ -118,7 +135,7 @@ const DropdownMenuItem = React.forwardRef<
ref,
) => {
const iconSlot = (
- <span className="hidden size-4 shrink-0 items-center justify-center group-has-[[data-has-icon]]/menu:flex">
+ <span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex">
{icon}
</span>
);
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
index 09ba0a9a..77d8ab7f 100644
--- a/apps/web/src/components/ui/font-picker.tsx
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -1,13 +1,6 @@
"use client";
-import {
- useState,
- useMemo,
- useRef,
- useEffect,
- useCallback,
- type CSSProperties,
-} from "react";
+import { useState, useMemo, useRef, useEffect, useCallback, type CSSProperties } from "react";
import { List, type RowComponentProps } from "react-window";
import {
Popover,
@@ -16,28 +9,24 @@ import {
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
-import {
- getCachedFontAtlas,
- loadFullFont,
- prefetchFontAtlas,
- clearFontAtlasCache,
-} from "@/lib/fonts/google-fonts";
-import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
+import { loadFullFont } from "@/lib/fonts/google-fonts";
+import { SYSTEM_FONTS } from "@/constants/font-constants";
+import type { FontAtlas, FontAtlasEntry } from "@/lib/fonts/types";
+import { useFontAtlas } from "@/hooks/use-font-atlas";
import { cn } from "@/utils/ui";
-import { ChevronDown, Search, Upload } from "lucide-react";
+import { ChevronDown, Search } from "lucide-react";
import { HugeiconsIcon } from "@hugeicons/react";
import { TextIcon } from "@hugeicons/core-free-icons";
const FONT_TABS = [
{ key: "all", label: "All fonts" },
- { key: "favorites", label: "Favorites" },
{ key: "my-fonts", label: "My fonts" },
+ { key: "favorites", label: "Favorites" },
] as const;
type FontTab = (typeof FONT_TABS)[number]["key"];
const ROW_HEIGHT = 40;
-const SPRITE_ROW_HEIGHT = 40;
const PREVIEW_SCALE = 0.8;
const LIST_WIDTH = 288;
const MAX_LIST_HEIGHT = 288;
@@ -57,18 +46,8 @@ export function FontPicker({
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState<FontTab>("all");
- const [atlas, setAtlas] = useState<FontAtlas | null>(() =>
- getCachedFontAtlas(),
- );
- const [status, setStatus] = useState<"idle" | "loading" | "error">(() =>
- getCachedFontAtlas() ? "idle" : "loading",
- );
const searchInputRef = useRef<HTMLInputElement>(null);
-
- const fontNames = useMemo(() => {
- if (!atlas) return [];
- return Object.keys(atlas.fonts).sort();
- }, [atlas]);
+ const { atlas, status, fontNames, retry: handleRetry } = useFontAtlas({ open });
const filteredFonts = useMemo(() => {
if (!search) return fontNames;
@@ -83,32 +62,19 @@ export function FontPicker({
const handleSelect = useCallback(
async ({ family }: { family: string }) => {
- try {
- await loadFullFont({ family });
- onValueChange?.(family);
- } catch {
- onValueChange?.(family);
+ if (!SYSTEM_FONTS.has(family)) {
+ try {
+ await loadFullFont({ family });
+ } catch {
+ // ignore load failure, font will fall back to system default
+ }
}
+ onValueChange?.(family);
setOpen(false);
},
[onValueChange],
);
- // Load atlas on first open if cache is empty (fallback when prefetch hasn't completed)
- useEffect(() => {
- if (!open || atlas) return;
-
- setStatus("loading");
- prefetchFontAtlas().then((data) => {
- if (data) {
- setAtlas(data);
- setStatus("idle");
- } else {
- setStatus("error");
- }
- });
- }, [open, atlas]);
-
useEffect(() => {
if (!open) {
setSearch("");
@@ -116,18 +82,8 @@ export function FontPicker({
}
}, [open]);
- const handleRetry = useCallback(() => {
- clearFontAtlasCache();
- setStatus("loading");
- prefetchFontAtlas().then((data) => {
- if (data) {
- setAtlas(data);
- setStatus("idle");
- } else {
- setStatus("error");
- }
- });
- }, []);
+ const activeTabLabel =
+ FONT_TABS.find((t) => t.key === activeTab)?.label.toLowerCase() ?? "";
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -164,11 +120,11 @@ export function FontPicker({
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" />
<Input
ref={searchInputRef}
- placeholder="Search fonts..."
+ placeholder={`Search ${activeTabLabel}...`}
value={search}
onChange={(event) => setSearch(event.target.value)}
size="xs"
- className="w-full pl-5 bg-transparent !border-none !shadow-none"
+ className="w-full pl-5 bg-transparent border-none! shadow-none!"
/>
</div>
<div className="flex border-b px-3">
@@ -225,20 +181,6 @@ export function FontPicker({
style={{ height: listHeight, width: LIST_WIDTH }}
/>
)}
- <div className="border-t p-1">
- <Button
- variant="ghost"
- size="sm"
- className="w-full justify-start text-muted-foreground h-8 font-normal"
- onClick={() => {
- // TODO: Implement local font loading
- console.log("Load local fonts clicked");
- }}
- >
- <Upload className="!size-3.5" />
- Load local fonts
- </Button>
- </div>
</PopoverContent>
</Popover>
);
@@ -250,7 +192,7 @@ function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) {
className="shrink-0"
style={{
width: entry.w,
- height: SPRITE_ROW_HEIGHT,
+ height: ROW_HEIGHT,
backgroundColor: "currentColor",
WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
WebkitMaskPosition: `-${entry.x}px -${entry.y}px`,
@@ -283,6 +225,7 @@ function FontRow({
const fontName = filteredFonts[index];
const entry = atlas.fonts[fontName];
const isSelected = fontName === selectedFont;
+ const isSystemFont = SYSTEM_FONTS.has(fontName);
return (
<button
@@ -302,7 +245,13 @@ function FontRow({
aria-label={fontName}
>
<div className="min-w-0 overflow-hidden">
- <FontSpritePreview entry={entry} />
+ {isSystemFont ? (
+ <span className="text-xl text-foreground/85" style={{ fontFamily: fontName }}>
+ {fontName}
+ </span>
+ ) : (
+ <FontSpritePreview entry={entry} />
+ )}
</div>
</button>
);
diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx
deleted file mode 100644
index 1c22fd2e..00000000
--- a/apps/web/src/components/ui/input-with-back.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { ArrowLeft, Search } from "lucide-react";
-import { motion } from "motion/react";
-import { useState, useEffect } from "react";
-
-interface InputWithBackProps {
- isExpanded: boolean;
- setIsExpanded: (isExpanded: boolean) => void;
- placeholder?: string;
- value?: string;
- onChange?: (value: string) => void;
- disableAnimation?: boolean;
-}
-
-export function InputWithBack({
- isExpanded,
- setIsExpanded,
- placeholder = "Search anything",
- value,
- onChange,
- disableAnimation = false,
-}: InputWithBackProps) {
- const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
- const [buttonOffset, setButtonOffset] = useState(-60);
-
- const smoothTransition = {
- duration: disableAnimation ? 0 : 0.35,
- ease: [0.25, 0.1, 0.25, 1] as const,
- };
-
- useEffect(() => {
- if (containerRef) {
- const rect = containerRef.getBoundingClientRect();
- setButtonOffset(-rect.left - 48);
- }
- }, [containerRef]);
-
- return (
- <div ref={setContainerRef} className="relative w-full">
- <motion.div
- className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
- initial={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- animate={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- transition={smoothTransition}
- onClick={() => setIsExpanded(!isExpanded)}
- >
- <Button variant="outline" className="bg-accent !size-9 rounded-full">
- <ArrowLeft />
- </Button>
- </motion.div>
- <div
- className="relative flex-1"
- style={{ marginLeft: "0px", paddingLeft: "0px" }}
- >
- <motion.div
- className="relative"
- initial={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- animate={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- transition={smoothTransition}
- >
- <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
- <Input
- placeholder={placeholder}
- className="bg-accent w-full pl-9"
- value={value}
- onChange={(e) => onChange?.(e.target.value)}
- />
- </motion.div>
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
index 762cc675..1dce2f24 100644
--- a/apps/web/src/components/ui/input.tsx
+++ b/apps/web/src/components/ui/input.tsx
@@ -8,18 +8,19 @@ import { forwardRef, type ComponentProps } from "react";
import { useState } from "react";
const inputVariants = cva(
- "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-offset-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
- default: "selection:bg-primary selection:text-primary-foreground",
+ default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20",
+ outline: "bg-background",
destructive:
"selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
},
size: {
default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
xs: "h-7 px-3 text-xs file:h-6 file:text-xs",
- sm: "h-8 px-3 text-sm file:h-6 file:text-xs",
+ sm: "h-7 px-3 text-sm file:h-6 file:text-xs",
lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
},
},
diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx
index 48297dc0..18d71591 100644
--- a/apps/web/src/components/ui/number-field.tsx
+++ b/apps/web/src/components/ui/number-field.tsx
@@ -1,12 +1,15 @@
"use client";
import { cn } from "@/utils/ui";
-import { useRef, useState, type ComponentProps } from "react";
+import { clamp } from "@/utils/math";
+import { useRef, useState, useLayoutEffect, type ComponentProps } from "react";
import { useFocusLock } from "@/hooks/use-focus-lock";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons";
+const SUFFIX_GAP_PX = 6;
+
const DRAG_SENSITIVITIES = {
default: 1,
slow: 0.5,
@@ -14,10 +17,94 @@ const DRAG_SENSITIVITIES = {
type DragSensitivity = "default" | "slow";
+type ScrubRange = {
+ from: number;
+ to: number;
+ pixelsPerUnit: number;
+};
+
+type ScrubClamp = {
+ min?: number;
+ max?: number;
+};
+
+function clampScrubValue({
+ value,
+ min,
+ max,
+}: {
+ value: number;
+ min?: number;
+ max?: number;
+}): number {
+ if (min != null && max != null) return clamp({ value, min, max });
+ if (min != null) return Math.max(min, value);
+ if (max != null) return Math.min(max, value);
+ return value;
+}
+
+function getActiveRange({
+ value,
+ direction,
+ ranges,
+}: {
+ value: number;
+ direction: number;
+ ranges: readonly ScrubRange[];
+}): ScrubRange | undefined {
+ return ranges.find((range) =>
+ direction > 0
+ ? value >= range.from && value < range.to
+ : value > range.from && value <= range.to,
+ );
+}
+
+function scrubAcrossRanges({
+ startValue,
+ pixelDelta,
+ ranges,
+ min,
+ max,
+}: {
+ startValue: number;
+ pixelDelta: number;
+ ranges: readonly ScrubRange[];
+ min?: number;
+ max?: number;
+}): number {
+ let currentValue = clampScrubValue({ value: startValue, min, max });
+ let remainingPixels = pixelDelta;
+
+ while (remainingPixels !== 0) {
+ const direction = Math.sign(remainingPixels);
+
+ const range = getActiveRange({ value: currentValue, direction, ranges });
+ if (!range) break;
+
+ const boundary = direction > 0 ? range.to : range.from;
+ const pixelsToBoundary =
+ Math.abs(boundary - currentValue) * range.pixelsPerUnit;
+
+ if (Math.abs(remainingPixels) <= pixelsToBoundary) {
+ currentValue += remainingPixels / range.pixelsPerUnit;
+ break;
+ }
+
+ currentValue = boundary;
+ remainingPixels -= direction * pixelsToBoundary;
+ }
+
+ return clampScrubValue({ value: currentValue, min, max });
+}
+
interface NumberFieldProps
extends Omit<ComponentProps<"input">, "size" | "type"> {
icon?: React.ReactNode;
+ suffix?: string;
+ suffixClassName?: string;
dragSensitivity?: DragSensitivity;
+ scrubRanges?: readonly ScrubRange[];
+ scrubClamp?: ScrubClamp;
onScrub?: (value: number) => void;
onScrubEnd?: () => void;
allowExpressions?: boolean;
@@ -28,8 +115,12 @@ interface NumberFieldProps
function NumberField({
className,
icon,
+ suffix,
+ suffixClassName,
disabled,
dragSensitivity = "default",
+ scrubRanges,
+ scrubClamp,
onScrub,
onScrubEnd,
value,
@@ -45,9 +136,18 @@ function NumberField({
}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) {
const iconRef = useRef<HTMLSpanElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
+ const ghostRef = useRef<HTMLSpanElement>(null);
const startValueRef = useRef(0);
const cumulativeDeltaRef = useRef(0);
const [isInputFocused, setIsInputFocused] = useState(false);
+ const [suffixLeft, setSuffixLeft] = useState(0);
+
+ useLayoutEffect(() => {
+ if (!suffix || !ghostRef.current || !inputRef.current) return;
+ const paddingLeft =
+ parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0;
+ setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth);
+ }, [value, suffix]);
const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({
isActive: isInputFocused,
@@ -71,12 +171,16 @@ function NumberField({
return;
}
cumulativeDeltaRef.current += moveEvent.movementX;
- const sensitivity =
- typeof dragSensitivity === "number"
- ? dragSensitivity
- : DRAG_SENSITIVITIES[dragSensitivity];
- const newValue =
- startValueRef.current + cumulativeDeltaRef.current * sensitivity;
+ const newValue = scrubRanges
+ ? scrubAcrossRanges({
+ startValue: startValueRef.current,
+ pixelDelta: cumulativeDeltaRef.current,
+ ranges: scrubRanges,
+ min: scrubClamp?.min,
+ max: scrubClamp?.max,
+ })
+ : startValueRef.current +
+ cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
onScrub(newValue);
};
@@ -93,11 +197,48 @@ function NumberField({
const canScrub = Boolean(icon && onScrub);
+ const inputNode = (
+ <input
+ type={allowExpressions ? "text" : "number"}
+ inputMode={allowExpressions ? "decimal" : undefined}
+ ref={inputRef}
+ disabled={disabled}
+ value={value}
+ className="text-sm leading-none bg-transparent outline-none min-w-0 flex-1 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ onMouseDown={(event) => {
+ const inputElement = event.currentTarget;
+ const shouldPreventNativeCaretPlacement =
+ event.button === 0 && document.activeElement !== inputElement;
+ if (shouldPreventNativeCaretPlacement) {
+ event.preventDefault();
+ inputElement.focus();
+ inputElement.select();
+ }
+ onMouseDown?.(event);
+ }}
+ onFocus={(event) => {
+ setIsInputFocused(true);
+ event.currentTarget.select();
+ onFocus?.(event);
+ }}
+ onKeyDown={(event) => {
+ const shouldBlurInput = event.key === "Enter" || event.key === "Escape";
+ if (shouldBlurInput) event.currentTarget.blur();
+ onKeyDown?.(event);
+ }}
+ onBlur={(event) => {
+ setIsInputFocused(false);
+ onBlur?.(event);
+ }}
+ {...props}
+ />
+ );
+
return (
<div
ref={wrapperRef}
className={cn(
- "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
+ "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none cursor-text disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
disabled && "pointer-events-none cursor-not-allowed opacity-50",
className,
)}
@@ -106,53 +247,45 @@ function NumberField({
<span
ref={iconRef}
className={cn(
- "text-muted-foreground [&_svg]:!size-3.5 shrink-0 select-none pl-2.5 text-sm leading-none",
+ "text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none",
canScrub && "cursor-ew-resize",
)}
+ onMouseDown={canScrub ? (event) => event.preventDefault() : undefined}
onPointerDown={canScrub ? handleIconPointerDown : undefined}
>
{icon}
</span>
)}
- <input
- type={allowExpressions ? "text" : "number"}
- inputMode={allowExpressions ? "decimal" : undefined}
- ref={inputRef}
- disabled={disabled}
- value={value}
+ <span
className={cn(
- "min-w-0 flex-1 text-sm leading-none bg-transparent outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
+ "relative flex flex-1 min-w-0 items-center",
icon ? "px-1.5" : "pl-2.5",
onReset ? "pr-0" : "pr-2.5",
)}
- onMouseDown={(event) => {
- const inputElement = event.currentTarget;
- const shouldPreventNativeCaretPlacement =
- event.button === 0 && document.activeElement !== inputElement;
- if (shouldPreventNativeCaretPlacement) {
- event.preventDefault();
- inputElement.focus();
- inputElement.select();
- }
- onMouseDown?.(event);
- }}
- onFocus={(event) => {
- setIsInputFocused(true);
- event.currentTarget.select();
- onFocus?.(event);
- }}
- onKeyDown={(event) => {
- const shouldBlurInput =
- event.key === "Enter" || event.key === "Escape";
- if (shouldBlurInput) event.currentTarget.blur();
- onKeyDown?.(event);
- }}
- onBlur={(event) => {
- setIsInputFocused(false);
- onBlur?.(event);
- }}
- {...props}
- />
+ >
+ {inputNode}
+ {suffix && (
+ <>
+ {/* Ghost mirrors value text to measure width for suffix positioning */}
+ <span
+ ref={ghostRef}
+ className="invisible absolute text-sm leading-none whitespace-pre pointer-events-none"
+ aria-hidden="true"
+ >
+ {value}
+ </span>
+ <span
+ className={cn(
+ "absolute top-1/2 -translate-y-1/2 select-none pointer-events-none text-sm leading-none",
+ suffixClassName,
+ )}
+ style={{ left: suffixLeft + SUFFIX_GAP_PX }}
+ >
+ {suffix}
+ </span>
+ </>
+ )}
+ </span>
{onReset && !isDefault && (
<div className="shrink-0 pr-2 flex items-center">
<Button
@@ -161,7 +294,7 @@ function NumberField({
aria-label="Reset to default"
onClick={onReset}
>
- <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="!size-3.5" />
+ <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="size-3.5!" />
</Button>
</div>
)}
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index 8fd511a3..cf480c8c 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -3,8 +3,26 @@
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Popover = PopoverPrimitive.Root;
+function Popover({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "popover",
+ open,
+ onOpenChange,
+ });
+ return (
+ <PopoverPrimitive.Root
+ open={open}
+ onOpenChange={handleOpenChange}
+ {...props}
+ />
+ );
+}
const PopoverTrigger = PopoverPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 1b57ea4d..b7092b87 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -7,8 +7,22 @@ import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Select = SelectPrimitive.Root;
+function Select({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "select",
+ open,
+ onOpenChange,
+ });
+ return (
+ <SelectPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
const SelectGroup = SelectPrimitive.Group;
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index 3155787d..d56cd0ac 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -4,10 +4,23 @@ import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
-
import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
-const Sheet = SheetPrimitive.Root;
+function Sheet({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "sheet",
+ open,
+ onOpenChange,
+ });
+ return (
+ <SheetPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
const SheetTrigger = SheetPrimitive.Trigger;
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx
index 4bae2fad..f0c8197a 100644
--- a/apps/web/src/components/ui/sonner.tsx
+++ b/apps/web/src/components/ui/sonner.tsx
@@ -12,7 +12,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
- position="top-center"
+ position="bottom-right"
offset={20}
toastOptions={{
classNames: {
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx
index d2844b63..c5cc02d7 100644
--- a/apps/web/src/components/ui/tabs.tsx
+++ b/apps/web/src/components/ui/tabs.tsx
@@ -5,51 +5,81 @@ import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/utils/ui";
-const Tabs = TabsPrimitive.Root;
+type TabsVariant = "default" | "underline";
+
+const TabsVariantContext = React.createContext<TabsVariant>("default");
+
+const Tabs = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & {
+ variant?: TabsVariant;
+ }
+>(({ variant = "default", ...props }, ref) => (
+ <TabsVariantContext.Provider value={variant}>
+ <TabsPrimitive.Root ref={ref} {...props} />
+ </TabsVariantContext.Provider>
+));
+Tabs.displayName = TabsPrimitive.Root.displayName;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
->(({ className, ...props }, ref) => (
- <TabsPrimitive.List
- ref={ref}
- className={cn(
- "text-muted-foreground inline-flex h-auto items-center justify-center gap-2 rounded-lg bg-transparent p-0",
- className,
- )}
- {...props}
- />
-));
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.List
+ ref={ref}
+ className={cn(
+ "text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0",
+ variant === "default" && "rounded-lg",
+ variant === "underline" && "border-b border-border w-full gap-0 px-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
->(({ className, ...props }, ref) => (
- <TabsPrimitive.Trigger
- ref={ref}
- className={cn(
- "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-accent data-[state=active]:text-foreground inline-flex cursor-pointer items-center justify-center rounded-lg px-3 py-1 text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
- className,
- )}
- {...props}
- />
-));
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent",
+ variant === "default" &&
+ "data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
+ variant === "underline" &&
+ "text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
->(({ className, ...props }, ref) => (
- <TabsPrimitive.Content
- ref={ref}
- className={cn(
- "ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
- className,
- )}
- {...props}
- />
-));
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.Content
+ ref={ref}
+ className={cn(
+ "ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
+ variant === "underline" && "px-4",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index 067042e6..2e7b53d4 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -11,7 +11,7 @@ const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const tooltipVariants = cva(
- "z-50 overflow-visible rounded-sm text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ "z-50 overflow-visible rounded-sm text-sm shadow-md",
{
variants: {
variant: {
@@ -22,7 +22,7 @@ const tooltipVariants = cva(
important:
"bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]",
promotions:
- "bg-red-100/90 text-red-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]",
+ "bg-red-100/90 text-redb-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]",
personal:
"bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]",
updates:
diff --git a/apps/web/src/components/ui/use-overlay-open-change.ts b/apps/web/src/components/ui/use-overlay-open-change.ts
new file mode 100644
index 00000000..165efc79
--- /dev/null
+++ b/apps/web/src/components/ui/use-overlay-open-change.ts
@@ -0,0 +1,78 @@
+import { useCallback, useEffect, useId, useRef } from "react";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+
+export function useOverlayOpenChange({
+ source,
+ open,
+ onOpenChange,
+}: {
+ source: string;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const { openOverlay, closeOverlay } = useKeybindingsStore();
+ const isTrackedRef = useRef(false);
+ const isControlled = typeof open === "boolean";
+ const overlayId = useId();
+
+ useEffect(() => {
+ if (!isControlled) return;
+
+ if (open && !isTrackedRef.current) {
+ openOverlay(overlayId, source);
+ isTrackedRef.current = true;
+ return;
+ }
+
+ if (!open && isTrackedRef.current) {
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ }
+ }, [closeOverlay, isControlled, open, openOverlay, overlayId, source]);
+
+ useEffect(() => {
+ return () => {
+ if (!isTrackedRef.current) return;
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "post-fix",
+ hypothesisId: "H2",
+ location: "use-overlay-open-change.ts:cleanup",
+ message: "Overlay closed during unmount cleanup",
+ data: { source, overlayId },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ };
+ }, [closeOverlay, overlayId, source]);
+
+ return useCallback(
+ (nextOpen: boolean) => {
+ if (!isControlled) {
+ if (nextOpen && !isTrackedRef.current) {
+ openOverlay(overlayId, source);
+ isTrackedRef.current = true;
+ } else if (!nextOpen && isTrackedRef.current) {
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ }
+ }
+
+ onOpenChange?.(nextOpen);
+ },
+ [closeOverlay, isControlled, onOpenChange, openOverlay, overlayId, source],
+ );
+}
diff --git a/apps/web/src/constants/editor-constants.ts b/apps/web/src/constants/editor-constants.ts
index 00e8f19b..6428af32 100644
--- a/apps/web/src/constants/editor-constants.ts
+++ b/apps/web/src/constants/editor-constants.ts
@@ -1,3 +1,17 @@
+export type TPlatformLayout =
+ | "tiktok"
+ | "instagram"
+ | "youtube"
+ | "snapchat";
+
+export const PREVIEW_ZOOM_PRESETS = [25, 50, 75, 100, 150, 200];
+
+export const PREVIEW_ZOOM = {
+ min: 0.25,
+ max: 16,
+ step: 1.25,
+};
+
export const PANEL_CONFIG = {
panels: {
tools: 25,
diff --git a/apps/web/src/constants/export-constants.ts b/apps/web/src/constants/export-constants.ts
index 4e27aae5..ffb5edc6 100644
--- a/apps/web/src/constants/export-constants.ts
+++ b/apps/web/src/constants/export-constants.ts
@@ -1,4 +1,4 @@
-import type { ExportOptions } from "@/types/export";
+import type { ExportOptions } from "@/lib/export";
export const DEFAULT_EXPORT_OPTIONS = {
format: "mp4",
diff --git a/apps/web/src/constants/font-constants.ts b/apps/web/src/constants/font-constants.ts
index d115d09b..9cc363a3 100644
--- a/apps/web/src/constants/font-constants.ts
+++ b/apps/web/src/constants/font-constants.ts
@@ -1,5 +1,3 @@
-export const DEFAULT_FONT = "Arial";
-
export const SYSTEM_FONTS = new Set([
"Arial",
"Helvetica",
diff --git a/apps/web/src/constants/guide-constants.ts b/apps/web/src/constants/guide-constants.ts
new file mode 100644
index 00000000..0b707de7
--- /dev/null
+++ b/apps/web/src/constants/guide-constants.ts
@@ -0,0 +1,4 @@
+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/constants/language-constants.ts b/apps/web/src/constants/language-constants.ts
index 3da36661..e5678145 100644
--- a/apps/web/src/constants/language-constants.ts
+++ b/apps/web/src/constants/language-constants.ts
@@ -9,3 +9,6 @@ export const LANGUAGES = [
{ 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/constants/mask-constants.ts b/apps/web/src/constants/mask-constants.ts
new file mode 100644
index 00000000..97e77ff0
--- /dev/null
+++ b/apps/web/src/constants/mask-constants.ts
@@ -0,0 +1,4 @@
+export const MAX_FEATHER = 1000;
+export const FEATHER_HANDLE_SCALE = 0.11;
+export const DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO = 0.6;
+export const MIN_MASK_DIMENSION = 0.01;
diff --git a/apps/web/src/constants/project-constants.ts b/apps/web/src/constants/project-constants.ts
index 7e3f06b6..24e75381 100644
--- a/apps/web/src/constants/project-constants.ts
+++ b/apps/web/src/constants/project-constants.ts
@@ -1,4 +1,4 @@
-import type { TCanvasSize } from "@/types/project";
+import type { TCanvasSize } from "@/lib/project/types";
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
{ width: 1920, height: 1080 },
@@ -16,12 +16,12 @@ export const FPS_PRESETS = [
] as const;
export const BLUR_INTENSITY_PRESETS: { label: string; value: number }[] = [
- { label: "Light", value: 4 },
- { label: "Medium", value: 8 },
- { label: "Heavy", value: 18 },
+ { label: "Light", value: 10 },
+ { label: "Medium", value: 50 },
+ { label: "Heavy", value: 100 },
] as const;
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
export const DEFAULT_FPS = 30;
-export const DEFAULT_BLUR_INTENSITY = 8;
+export const DEFAULT_BLUR_INTENSITY = 10;
export const DEFAULT_COLOR = "#000000";
diff --git a/apps/web/src/constants/retime-constants.ts b/apps/web/src/constants/retime-constants.ts
new file mode 100644
index 00000000..7b18b61c
--- /dev/null
+++ b/apps/web/src/constants/retime-constants.ts
@@ -0,0 +1,30 @@
+export const DEFAULT_RETIME_RATE = 1;
+export const MIN_RETIME_RATE = 0.01;
+export const MAX_RETIME_RATE = 5;
+export const MAX_PITCH_PRESERVE_RATE = 10;
+
+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 {
+ if (!Number.isFinite(rate) || rate <= 0) {
+ return false;
+ }
+
+ return clampRetimeRate({ rate }) <= MAX_PITCH_PRESERVE_RATE;
+}
+
+export function shouldMaintainPitch({
+ rate,
+ maintainPitch,
+}: {
+ rate: number;
+ maintainPitch?: boolean;
+}): boolean {
+ return maintainPitch === true && canMaintainPitch({ rate });
+}
diff --git a/apps/web/src/constants/sticker-constants.ts b/apps/web/src/constants/sticker-constants.ts
index 506e552d..06692645 100644
--- a/apps/web/src/constants/sticker-constants.ts
+++ b/apps/web/src/constants/sticker-constants.ts
@@ -1,8 +1,8 @@
+export const STICKER_INTRINSIC_SIZE_FALLBACK = 200;
+
export const STICKER_CATEGORIES = {
all: "All",
logos: "Logos",
- icons: "Icons",
- emoji: "Emoji",
flags: "Flags",
shapes: "Shapes",
};
diff --git a/apps/web/src/constants/text-constants.ts b/apps/web/src/constants/text-constants.ts
index 6779670b..45f76bc8 100644
--- a/apps/web/src/constants/text-constants.ts
+++ b/apps/web/src/constants/text-constants.ts
@@ -1,10 +1,3 @@
-import type { TextElement } from "@/types/timeline";
-import {
- DEFAULT_OPACITY,
- DEFAULT_TRANSFORM,
- TIMELINE_CONSTANTS,
-} from "./timeline-constants";
-
export const MIN_FONT_SIZE = 5;
export const MAX_FONT_SIZE = 300;
@@ -14,40 +7,5 @@ export const MAX_FONT_SIZE = 300;
*/
export const FONT_SIZE_SCALE_REFERENCE = 90;
-export const DEFAULT_LETTER_SPACING = 0;
-export const DEFAULT_LINE_HEIGHT = 1.2;
-
export const CORNER_RADIUS_MIN = 0;
export const CORNER_RADIUS_MAX = 100;
-
-export const DEFAULT_TEXT_BACKGROUND = {
- enabled: false,
- color: "#000000",
- cornerRadius: 0,
- paddingX: 30,
- paddingY: 42,
- offsetX: 0,
- offsetY: 0,
-};
-
-export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
- type: "text",
- name: "Text",
- content: "Default text",
- fontSize: 15,
- fontFamily: "Arial",
- color: "#ffffff",
- background: DEFAULT_TEXT_BACKGROUND,
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- letterSpacing: DEFAULT_LETTER_SPACING,
- lineHeight: DEFAULT_LINE_HEIGHT,
- duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- transform: DEFAULT_TRANSFORM,
- opacity: DEFAULT_OPACITY,
-};
diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts
new file mode 100644
index 00000000..827fab85
--- /dev/null
+++ b/apps/web/src/constants/timeline-constants.ts
@@ -0,0 +1,85 @@
+import type { ElementType, TrackType } from "@/lib/timeline";
+
+export const TRACK_CONFIG: Record<
+ TrackType,
+ {
+ height: number;
+ defaultName: string;
+ }
+> = {
+ video: {
+ height: 65,
+ defaultName: "Video track",
+ },
+ text: {
+ height: 25,
+ defaultName: "Text track",
+ },
+ audio: {
+ height: 50,
+ defaultName: "Audio track",
+ },
+ graphic: {
+ height: 25,
+ defaultName: "Graphic track",
+ },
+ effect: {
+ height: 25,
+ defaultName: "Effect track",
+ },
+} as const;
+
+export const ELEMENT_TYPE_CONFIG: Record<
+ TrackType,
+ {
+ background: string;
+ waveformColor?: string;
+ }
+> = {
+ video: { background: "transparent" },
+ text: { background: "bg-[#5DBAA0]" },
+ audio: {
+ background: "bg-[#8F5DBA]",
+ waveformColor: "rgba(255, 255, 255, 0.5)",
+ },
+ graphic: { background: "bg-[#BA5D7A]" },
+ effect: { background: "bg-[#5d93ba]" },
+} as const;
+
+export const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
+ audio: "audio",
+ text: "text",
+ sticker: "graphic",
+ graphic: "graphic",
+ effect: "effect",
+ video: "video",
+ image: "video",
+};
+
+export const TRACK_GAP = 6;
+export const TRACK_LABELS_WIDTH_PX = 112;
+
+export const TIMELINE_RULER_HEIGHT = 22;
+export const TIMELINE_BOOKMARK_ROW_HEIGHT = 16;
+
+export const DEFAULT_BOOKMARK_COLOR = "#009dff";
+export const DRAG_THRESHOLD_PX = 5;
+export const TIMELINE_SCROLLBAR_SIZE_PX = 12;
+export const TIMELINE_LAYERS = {
+ trackContent: 10,
+ dragLine: 20,
+ playhead: 30,
+ snapIndicator: 40,
+} as const;
+
+export const TIMELINE_CONSTANTS = {
+ PIXELS_PER_SECOND: 50,
+ DEFAULT_ELEMENT_DURATION: 5,
+ PADDING_TOP_PX: 2,
+ HORIZONTAL_WHEEL_STEP_PX: 40,
+ ZOOM_MIN: 0.1,
+ ZOOM_MAX: 100,
+ ZOOM_BUTTON_FACTOR: 1.7,
+ ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15,
+ TRACK_SELECTED_BG: "bg-accent/50",
+} as const;
diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx
deleted file mode 100644
index 5bc938bb..00000000
--- a/apps/web/src/constants/timeline-constants.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import type { TTimelineViewState } from "@/types/project";
-import type { BlendMode } from "@/types/rendering";
-import type { TrackType, Transform } from "@/types/timeline";
-import {
- Happy01Icon,
- MagicWand05Icon,
- MusicNote03Icon,
- TextIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { OcVideoIcon } from "@opencut/ui/icons";
-
-export const DEFAULT_TRANSFORM: Transform = {
- scale: 1,
- position: { x: 0, y: 0 },
- rotate: 0,
-};
-
-export const DEFAULT_OPACITY = 1;
-export const DEFAULT_BLEND_MODE: BlendMode = "normal";
-export const DEFAULT_BOOKMARK_COLOR = "#009dff";
-
-export const TRACK_CONFIG: Record<
- TrackType,
- {
- background: string;
- height: number;
- defaultName: string;
- icon: React.ReactNode;
- }
-> = {
- video: {
- background: "transparent",
- height: 60,
- defaultName: "Video track",
- icon: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
- },
- text: {
- background: "bg-[#5DBAA0]",
- height: 25,
- defaultName: "Text track",
- icon: (
- <HugeiconsIcon
- icon={TextIcon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
- },
- audio: {
- background: "bg-[#8F5DBA]",
- height: 50,
- defaultName: "Audio track",
- icon: (
- <HugeiconsIcon
- icon={MusicNote03Icon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
- },
- sticker: {
- background: "bg-[#BA5D7A]",
- height: 50,
- defaultName: "Sticker track",
- icon: (
- <HugeiconsIcon
- icon={Happy01Icon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
- },
- effect: {
- background: "bg-[#5d93ba]",
- height: 25,
- defaultName: "Effect track",
- icon: (
- <HugeiconsIcon
- icon={MagicWand05Icon}
- className="text-muted-foreground size-4 shrink-0"
- />
- ),
- },
-} as const;
-
-export const TRACK_GAP = 4;
-
-export const DRAG_THRESHOLD_PX = 5;
-
-export const TIMELINE_CONSTANTS = {
- PIXELS_PER_SECOND: 50,
- DEFAULT_ELEMENT_DURATION: 5,
- PADDING_TOP_PX: 0,
- ZOOM_MIN: 0.1,
- ZOOM_MAX: 100,
- ZOOM_BUTTON_FACTOR: 1.7,
- ZOOM_ANCHOR_PLAYHEAD_THRESHOLD: 0.15,
-} as const;
-
-export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
- zoomLevel: 1,
- scrollLeft: 0,
- playheadTime: 0,
-};
diff --git a/apps/web/src/constants/transcription-constants.ts b/apps/web/src/constants/transcription-constants.ts
index ed19147c..50b52585 100644
--- a/apps/web/src/constants/transcription-constants.ts
+++ b/apps/web/src/constants/transcription-constants.ts
@@ -2,8 +2,8 @@ import { LANGUAGES } from "@/constants/language-constants";
import type {
TranscriptionModel,
TranscriptionModelId,
-} from "@/types/transcription";
-import type { LanguageCode } from "@/types/language";
+} from "@/lib/transcription/types";
+import type { LanguageCode } from "@/constants/language-constants";
const SUPPORTED_TRANSCRIPTION_LANGS: ReadonlyArray<LanguageCode> = [
"en",
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts
index 42ab3b87..e7cc5496 100644
--- a/apps/web/src/core/index.ts
+++ b/apps/web/src/core/index.ts
@@ -9,13 +9,13 @@ import { SaveManager } from "./managers/save-manager";
import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager";
import { registerDefaultEffects } from "@/lib/effects";
+import { registerDefaultMasks } from "@/lib/masks";
export class EditorCore {
private static instance: EditorCore | null = null;
-
+ public readonly timeline: TimelineManager;
public readonly command: CommandManager;
public readonly playback: PlaybackManager;
- public readonly timeline: TimelineManager;
public readonly scenes: ScenesManager;
public readonly project: ProjectManager;
public readonly media: MediaManager;
@@ -26,9 +26,10 @@ export class EditorCore {
private constructor() {
registerDefaultEffects();
+ registerDefaultMasks();
this.command = new CommandManager();
- this.playback = new PlaybackManager(this);
this.timeline = new TimelineManager(this);
+ this.playback = new PlaybackManager(this);
this.scenes = new ScenesManager(this);
this.project = new ProjectManager(this);
this.media = new MediaManager(this);
diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts
index 20b124b6..54fda6c2 100644
--- a/apps/web/src/core/managers/audio-manager.ts
+++ b/apps/web/src/core/managers/audio-manager.ts
@@ -1,7 +1,21 @@
import type { EditorCore } from "@/core";
+import {
+ clampRetimeRate,
+ shouldMaintainPitch,
+} from "@/constants/retime-constants";
import type { AudioClipSource } from "@/lib/media/audio";
import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
import {
+ buildAudioGainAutomation,
+ hasAnimatedVolume,
+} from "@/lib/timeline/audio-state";
+import { createAudioMasteringChain } from "@/lib/media/audio-mastering";
+import {
+ getClipTimeAtSourceTime,
+ getSourceTimeAtClipTime,
+ renderRetimedBuffer,
+} from "@/lib/retime";
+import {
ALL_FORMATS,
AudioBufferSink,
BlobSource,
@@ -26,6 +40,8 @@ export class AudioManager {
AsyncGenerator<WrappedAudioBuffer, void, unknown>
>();
private queuedSources = new Set<AudioBufferSourceNode>();
+ private preparedClipBuffers = new Map<string, Promise<AudioBuffer | null>>();
+ private decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
private playbackSessionId = 0;
private lastIsPlaying = false;
private lastVolume = 1;
@@ -55,6 +71,8 @@ export class AudioManager {
window.removeEventListener("playback-seek", this.handleSeek);
}
this.disposeSinks();
+ this.preparedClipBuffers.clear();
+ this.decodedBuffers.clear();
if (this.audioContext) {
void this.audioContext.close();
this.audioContext = null;
@@ -102,6 +120,8 @@ export class AudioManager {
private handleTimelineChange = (): void => {
this.disposeSinks();
+ this.preparedClipBuffers.clear();
+ this.decodedBuffers.clear();
if (!this.editor.playback.getIsPlaying()) return;
@@ -113,9 +133,12 @@ export class AudioManager {
if (typeof window === "undefined") return null;
this.audioContext = createAudioContext();
- this.masterGain = this.audioContext.createGain();
+ const { input } = createAudioMasteringChain({
+ audioContext: this.audioContext,
+ destination: this.audioContext.destination,
+ });
+ this.masterGain = input;
this.masterGain.gain.value = this.lastVolume;
- this.masterGain.connect(this.audioContext.destination);
return this.audioContext;
}
@@ -179,11 +202,19 @@ export class AudioManager {
if (clip.startTime > windowEnd) continue;
this.activeClipIds.add(clip.id);
- void this.runClipIterator({
- clip,
- startTime: currentTime,
- sessionId: this.playbackSessionId,
- });
+ if (this.shouldUsePreparedClipBuffer({ clip })) {
+ void this.schedulePreparedClip({
+ clip,
+ startTime: currentTime,
+ sessionId: this.playbackSessionId,
+ });
+ } else {
+ void this.runClipIterator({
+ clip,
+ startTime: currentTime,
+ sessionId: this.playbackSessionId,
+ });
+ }
}
}
@@ -236,7 +267,11 @@ export class AudioManager {
return;
}
const sourceStartTime =
- clip.trimStart + (iteratorStartTime - clip.startTime);
+ clip.trimStart +
+ getSourceTimeAtClipTime({
+ clipTime: iteratorStartTime - clip.startTime,
+ retime: clip.retime,
+ });
const iterator = sink.buffers(sourceStartTime);
this.clipIterators.set(clip.id, iterator);
@@ -246,12 +281,23 @@ export class AudioManager {
if (!this.editor.playback.getIsPlaying()) return;
if (sessionId !== this.playbackSessionId) return;
- const timelineTime = clip.startTime + (timestamp - clip.trimStart);
+ const timelineTime =
+ clip.startTime +
+ getClipTimeAtSourceTime({
+ sourceTime: timestamp - clip.trimStart,
+ retime: clip.retime,
+ });
if (timelineTime >= clipEnd) break;
const node = audioContext.createBufferSource();
node.buffer = buffer;
- node.connect(this.masterGain ?? audioContext.destination);
+ if (clip.retime) {
+ node.playbackRate.value = clampRetimeRate({ rate: clip.retime.rate });
+ }
+ const clipGain = audioContext.createGain();
+ clipGain.gain.value = clip.volume;
+ node.connect(clipGain);
+ clipGain.connect(this.masterGain ?? audioContext.destination);
const startTimestamp =
this.playbackStartContextTime +
@@ -277,8 +323,7 @@ export class AudioManager {
nextCompensationSeconds >
this.playbackLatencyCompensationSeconds + 0.001
) {
- this.playbackLatencyCompensationSeconds =
- nextCompensationSeconds;
+ this.playbackLatencyCompensationSeconds = nextCompensationSeconds;
}
const resyncStartTime = this.getPlaybackTime();
this.clipIterators.delete(clip.id);
@@ -296,6 +341,7 @@ export class AudioManager {
this.queuedSources.add(node);
node.addEventListener("ended", () => {
node.disconnect();
+ clipGain.disconnect();
this.queuedSources.delete(node);
});
@@ -311,6 +357,73 @@ export class AudioManager {
// the set is cleared on stopPlayback anyway
}
+ private async schedulePreparedClip({
+ clip,
+ startTime,
+ sessionId,
+ }: {
+ clip: AudioClipSource;
+ startTime: number;
+ sessionId: number;
+ }): Promise<void> {
+ const audioContext = this.ensureAudioContext();
+ if (!audioContext) return;
+
+ const buffer = await this.getPreparedClipBuffer({ clip });
+ if (!buffer || !this.editor.playback.getIsPlaying()) return;
+ if (sessionId !== this.playbackSessionId) return;
+
+ const clipStart = clip.startTime;
+ const clipEnd = clip.startTime + clip.duration;
+ const playbackTimeAfterReady = this.getPlaybackTime();
+ const effectiveStartTime = Math.max(
+ startTime,
+ clipStart,
+ playbackTimeAfterReady,
+ );
+ if (effectiveStartTime >= clipEnd) {
+ return;
+ }
+
+ const node = audioContext.createBufferSource();
+ node.buffer = buffer;
+ const clipGain = audioContext.createGain();
+ node.connect(clipGain);
+ clipGain.connect(this.masterGain ?? audioContext.destination);
+
+ const startTimestamp =
+ this.playbackStartContextTime +
+ this.playbackLatencyCompensationSeconds +
+ (effectiveStartTime - this.playbackStartTime);
+ const clipOffset = effectiveStartTime - clipStart;
+ let actualStartTimestamp = startTimestamp;
+ let actualClipOffset = clipOffset;
+
+ if (startTimestamp >= audioContext.currentTime) {
+ node.start(startTimestamp, clipOffset);
+ } else {
+ const lateOffset = audioContext.currentTime - startTimestamp;
+ actualStartTimestamp = audioContext.currentTime;
+ actualClipOffset = clipOffset + lateOffset;
+ node.start(actualStartTimestamp, actualClipOffset);
+ }
+
+ this.scheduleClipGainAutomation({
+ audioContext,
+ clip,
+ clipGain,
+ startTimestamp: actualStartTimestamp,
+ startLocalTime: actualClipOffset,
+ });
+
+ this.queuedSources.add(node);
+ node.addEventListener("ended", () => {
+ node.disconnect();
+ clipGain.disconnect();
+ this.queuedSources.delete(node);
+ });
+ }
+
private waitUntilCaughtUp({
timelineTime,
targetAhead,
@@ -349,6 +462,221 @@ export class AudioManager {
this.sinks.clear();
}
+ private shouldUsePreparedClipBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): boolean {
+ return (
+ this.hasCurveRetime({ clip }) ||
+ hasAnimatedVolume({ element: clip.timelineElement }) ||
+ shouldMaintainPitch({
+ rate: clip.retime?.rate ?? 1,
+ maintainPitch: clip.retime?.maintainPitch,
+ })
+ );
+ }
+
+ private hasCurveRetime({ clip }: { clip: AudioClipSource }): boolean {
+ const mode = (clip.retime as { mode?: unknown } | undefined)?.mode;
+ return mode === "curve";
+ }
+
+ private scheduleClipGainAutomation({
+ audioContext,
+ clip,
+ clipGain,
+ startTimestamp,
+ startLocalTime,
+ }: {
+ audioContext: AudioContext;
+ clip: AudioClipSource;
+ clipGain: GainNode;
+ startTimestamp: number;
+ startLocalTime: number;
+ }): void {
+ clipGain.gain.cancelScheduledValues(startTimestamp);
+ clipGain.gain.setValueAtTime(clip.volume, startTimestamp);
+
+ if (!hasAnimatedVolume({ element: clip.timelineElement })) {
+ return;
+ }
+
+ const points = buildAudioGainAutomation({
+ element: clip.timelineElement,
+ fromLocalTime: startLocalTime,
+ toLocalTime: clip.duration,
+ });
+
+ if (points.length === 0) {
+ return;
+ }
+
+ clipGain.gain.setValueAtTime(points[0].gain, startTimestamp);
+ for (let index = 1; index < points.length; index++) {
+ const point = points[index];
+ const pointTimestamp =
+ startTimestamp + (point.localTime - startLocalTime);
+ if (pointTimestamp < audioContext.currentTime) {
+ continue;
+ }
+
+ clipGain.gain.linearRampToValueAtTime(point.gain, pointTimestamp);
+ }
+ }
+
+ private buildPreparedClipCacheKey({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): string {
+ return JSON.stringify({
+ id: clip.id,
+ sourceKey: clip.sourceKey,
+ startTime: clip.startTime,
+ duration: clip.duration,
+ trimStart: clip.trimStart,
+ trimEnd: clip.trimEnd,
+ retime: clip.retime ?? null,
+ });
+ }
+
+ private async getPreparedClipBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): Promise<AudioBuffer | null> {
+ const cacheKey = this.buildPreparedClipCacheKey({ clip });
+ const existing = this.preparedClipBuffers.get(cacheKey);
+ if (existing) {
+ return existing;
+ }
+
+ const promise = (async () => {
+ const audioContext = this.ensureAudioContext();
+ if (!audioContext) {
+ return null;
+ }
+
+ const decodedBuffer = await this.getDecodedBuffer({ clip });
+ if (!decodedBuffer) {
+ return null;
+ }
+
+ return await renderRetimedBuffer({
+ audioContext,
+ sourceBuffer: decodedBuffer,
+ trimStart: clip.trimStart,
+ clipDuration: clip.duration,
+ retime: clip.retime,
+ maintainPitch: clip.retime?.maintainPitch === true,
+ });
+ })();
+
+ this.preparedClipBuffers.set(cacheKey, promise);
+ return promise;
+ }
+
+ private async getDecodedBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): Promise<AudioBuffer | null> {
+ const existing = this.decodedBuffers.get(clip.sourceKey);
+ if (existing) {
+ return existing;
+ }
+
+ const promise = this.decodeClipBuffer({ clip });
+ this.decodedBuffers.set(clip.sourceKey, promise);
+ return promise;
+ }
+
+ private async decodeClipBuffer({
+ clip,
+ }: {
+ clip: AudioClipSource;
+ }): Promise<AudioBuffer | null> {
+ const audioContext = this.ensureAudioContext();
+ if (!audioContext) {
+ return null;
+ }
+
+ const input = new Input({
+ source: new BlobSource(clip.file),
+ formats: ALL_FORMATS,
+ });
+
+ try {
+ const audioTrack = await input.getPrimaryAudioTrack();
+ if (!audioTrack) {
+ return null;
+ }
+
+ const sink = new AudioBufferSink(audioTrack);
+ 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 targetSampleRate = audioContext.sampleRate;
+ const nativeSampleRate = chunks[0].sampleRate;
+ const numChannels = Math.min(2, 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++) {
+ nativeChannels[channel].set(
+ chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1)),
+ offset,
+ );
+ }
+ offset += chunk.length;
+ }
+
+ const outputSamples = Math.ceil(
+ totalSamples * (targetSampleRate / nativeSampleRate),
+ );
+ const offlineContext = new OfflineAudioContext(
+ numChannels,
+ outputSamples,
+ targetSampleRate,
+ );
+ const nativeBuffer = audioContext.createBuffer(
+ numChannels,
+ totalSamples,
+ nativeSampleRate,
+ );
+
+ for (let channel = 0; channel < numChannels; channel++) {
+ nativeBuffer.copyToChannel(nativeChannels[channel], channel);
+ }
+
+ 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 clip audio:", error);
+ return null;
+ } finally {
+ input.dispose();
+ }
+ }
+
private async getAudioSink({
clip,
}: {
diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts
index fd8726d9..cdab13ef 100644
--- a/apps/web/src/core/managers/media-manager.ts
+++ b/apps/web/src/core/managers/media-manager.ts
@@ -1,9 +1,10 @@
import type { EditorCore } from "@/core";
-import type { MediaAsset } from "@/types/assets";
+import { toast } from "sonner";
+import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service";
import { generateUUID } from "@/utils/id";
import { videoCache } from "@/services/video-cache/service";
-import { hasMediaId } from "@/lib/timeline/element-utils";
+import { BatchCommand, RemoveMediaAssetCommand } from "@/lib/commands";
export class MediaManager {
private assets: MediaAsset[] = [];
@@ -18,7 +19,7 @@ export class MediaManager {
}: {
projectId: string;
asset: Omit<MediaAsset, "id">;
- }): Promise<void> {
+ }): Promise<MediaAsset | null> {
const newAsset: MediaAsset = {
...asset,
id: generateUUID(),
@@ -29,54 +30,46 @@ export class MediaManager {
try {
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
+ return newAsset;
} catch (error) {
console.error("Failed to save media asset:", error);
this.assets = this.assets.filter((asset) => asset.id !== newAsset.id);
this.notify();
+
+ if (storageService.isQuotaExceededError({ error })) {
+ toast.error("Not enough browser storage", {
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+
+ return null;
}
}
- async removeMediaAsset({
+ removeMediaAsset({ projectId, id }: { projectId: string; id: string }): void {
+ this.removeMediaAssets({ projectId, ids: [id] });
+ }
+
+ removeMediaAssets({
projectId,
- id,
+ ids,
}: {
projectId: string;
- id: string;
- }): Promise<void> {
- const asset = this.assets.find((asset) => asset.id === id);
-
- videoCache.clearVideo({ mediaId: id });
-
- if (asset?.url) {
- URL.revokeObjectURL(asset.url);
- if (asset.thumbnailUrl) {
- URL.revokeObjectURL(asset.thumbnailUrl);
- }
+ ids: string[];
+ }): void {
+ const uniqueIds = [...new Set(ids)];
+ if (uniqueIds.length === 0) {
+ return;
}
- this.assets = this.assets.filter((asset) => asset.id !== id);
- this.notify();
+ const command =
+ uniqueIds.length === 1
+ ? new RemoveMediaAssetCommand(projectId, uniqueIds[0])
+ : new BatchCommand(
+ uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)),
+ );
- const tracks = this.editor.timeline.getTracks();
- const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
-
- for (const track of tracks) {
- for (const element of track.elements) {
- if (hasMediaId(element) && element.mediaId === id) {
- elementsToRemove.push({ trackId: track.id, elementId: element.id });
- }
- }
- }
-
- if (elementsToRemove.length > 0) {
- this.editor.timeline.deleteElements({ elements: elementsToRemove });
- }
-
- try {
- await storageService.deleteMediaAsset({ projectId, id });
- } catch (error) {
- console.error("Failed to delete media asset:", error);
- }
+ this.editor.command.execute({ command });
}
async loadProjectMedia({ projectId }: { projectId: string }): Promise<void> {
@@ -157,6 +150,8 @@ export class MediaManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts
index dacd76c0..5993a503 100644
--- a/apps/web/src/core/managers/playback-manager.ts
+++ b/apps/web/src/core/managers/playback-manager.ts
@@ -11,7 +11,15 @@ export class PlaybackManager {
private playbackTimer: number | null = null;
private lastUpdate = 0;
- constructor(private editor: EditorCore) {}
+ constructor(private editor: EditorCore) {
+ this.editor.timeline.subscribe(() => {
+ const duration = this.editor.timeline.getTotalDuration();
+ if (this.currentTime > duration && duration > 0) {
+ this.currentTime = duration;
+ this.notify();
+ }
+ });
+ }
play(): void {
const duration = this.editor.timeline.getTotalDuration();
@@ -117,7 +125,9 @@ export class PlaybackManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
private startTimer(): void {
@@ -158,7 +168,6 @@ export class PlaybackManager {
);
} else {
this.currentTime = newTime;
- this.notify();
window.dispatchEvent(
new CustomEvent("playback-update", {
diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts
index 05889a94..f84a25e3 100644
--- a/apps/web/src/core/managers/project-manager.ts
+++ b/apps/web/src/core/managers/project-manager.ts
@@ -6,8 +6,8 @@ import type {
TProjectSortOption,
TProjectSettings,
TTimelineViewState,
-} from "@/types/project";
-import type { ExportOptions, ExportResult, ExportState } from "@/types/export";
+} from "@/lib/project/types";
+import type { ExportOptions, ExportResult, ExportState } from "@/lib/export";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
@@ -26,9 +26,9 @@ import {
runStorageMigrations,
type MigrationProgress,
} from "@/services/storage/migrations";
-import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
import { loadFonts } from "@/lib/fonts/google-fonts";
-import { collectFontFamilies } from "@/lib/timeline/element-utils";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { getElementFontFamilies } from "@/lib/timeline/element-utils";
export interface MigrationState {
isMigrating: boolean;
@@ -94,6 +94,8 @@ export class ProjectManager {
settings: {
fps: DEFAULT_FPS,
canvasSize: DEFAULT_CANVAS_SIZE,
+ canvasSizeMode: "preset",
+ lastCustomCanvasSize: null,
originalCanvasSize: null,
background: {
type: "color",
@@ -155,7 +157,9 @@ export class ProjectManager {
await this.editor.media.loadProjectMedia({ projectId: id });
const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks);
- await loadFonts({ families: collectFontFamilies({ tracks: allTracks }) });
+ await loadFonts({
+ families: getElementFontFamilies({ tracks: allTracks }),
+ });
if (!project.metadata.thumbnail) {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
@@ -239,14 +243,21 @@ export class ProjectManager {
this.notify();
}
- await this.ensureStorageMigrations();
try {
- const metadata = await storageService.loadAllProjectsMetadata();
- this.savedProjects = metadata;
- this.notify();
+ await this.ensureStorageMigrations();
+ try {
+ const metadata = await storageService.loadAllProjectsMetadata();
+ this.savedProjects = metadata;
+ this.notify();
+ } catch (error) {
+ console.error("Failed to load projects:", error);
+ } finally {
+ this.isLoading = false;
+ this.isInitialized = true;
+ this.notify();
+ }
} catch (error) {
- console.error("Failed to load projects:", error);
- } finally {
+ console.error("Failed to run migrations:", error);
this.isLoading = false;
this.isInitialized = true;
this.notify();
@@ -495,10 +506,12 @@ export class ProjectManager {
}
async prepareExit(): Promise<void> {
+ console.log("prepareExit", this.active);
if (!this.active) return;
try {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
+ console.log("didUpdateThumbnail", didUpdateThumbnail);
if (didUpdateThumbnail) {
await this.editor.save.flush();
}
@@ -571,7 +584,7 @@ export class ProjectManager {
}
getTimelineViewState(): TTimelineViewState {
- return this.active?.timelineViewState ?? DEFAULT_TIMELINE_VIEW_STATE;
+ return this.active?.timelineViewState ?? DEFAULTS.timeline.viewState;
}
setTimelineViewState({ viewState }: { viewState: TTimelineViewState }): void {
@@ -581,6 +594,7 @@ export class ProjectManager {
timelineViewState: viewState ?? undefined,
};
this.editor.save.markDirty();
+ this.notify();
}
getSavedProjects(): TProjectMetadata[] {
@@ -615,15 +629,12 @@ export class ProjectManager {
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
const duration = this.editor.timeline.getTotalDuration();
-
- if (duration === 0) return false;
-
const { canvasSize, background } = this.active.settings;
const scene = buildScene({
tracks,
mediaAssets,
- duration,
+ duration: duration || 1,
canvasSize,
background,
});
@@ -656,7 +667,7 @@ export class ProjectManager {
);
if (index !== -1) {
- this.savedProjects[index] = project.metadata;
+ this.savedProjects = this.savedProjects.with(index, project.metadata);
} else {
this.savedProjects = [project.metadata, ...this.savedProjects];
}
@@ -665,6 +676,8 @@ export class ProjectManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts
index 601a007e..75184c53 100644
--- a/apps/web/src/core/managers/renderer-manager.ts
+++ b/apps/web/src/core/managers/renderer-manager.ts
@@ -1,6 +1,6 @@
import type { EditorCore } from "@/core";
import type { RootNode } from "@/services/renderer/nodes/root-node";
-import type { ExportOptions, ExportResult } from "@/types/export";
+import type { ExportOptions, ExportResult } from "@/lib/export";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
import { buildScene } from "@/services/renderer/scene-builder";
@@ -8,6 +8,10 @@ import { createTimelineAudioBuffer } from "@/lib/media/audio";
import { formatTimeCode, getLastFrameTime } from "@/lib/time";
import { downloadBlob } from "@/utils/browser";
+type SnapshotResult =
+ | { success: true; blob: Blob; filename: string }
+ | { success: false; error: string };
+
export class RendererManager {
private renderTree: RootNode | null = null;
private listeners = new Set<() => void>();
@@ -24,6 +28,45 @@ export class RendererManager {
}
async saveSnapshot(): Promise<{ success: boolean; error?: string }> {
+ const snapshot = await this.createSnapshot();
+ if (!snapshot.success) {
+ return snapshot;
+ }
+
+ downloadBlob({ blob: snapshot.blob, filename: snapshot.filename });
+ return { success: true };
+ }
+
+ async copySnapshot(): Promise<{ success: boolean; error?: string }> {
+ if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) {
+ return {
+ success: false,
+ error: "Clipboard image copy is not supported in this browser",
+ };
+ }
+
+ const snapshot = await this.createSnapshot();
+ if (!snapshot.success) {
+ return snapshot;
+ }
+
+ try {
+ await navigator.clipboard.write([
+ new ClipboardItem({
+ [snapshot.blob.type || "image/png"]: snapshot.blob,
+ }),
+ ]);
+ return { success: true };
+ } catch (error) {
+ console.error("Copy snapshot failed:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ };
+ }
+ }
+
+ private async createSnapshot(): Promise<SnapshotResult> {
try {
const renderTree = this.getRenderTree();
const activeProject = this.editor.project.getActive();
@@ -70,15 +113,14 @@ export class RendererManager {
timeInSeconds: renderTime,
fps,
}).replace(/:/g, "-");
- const safeName = activeProject.metadata.name
- .replace(/[<>:"/\\|?*]/g, "-")
- .trim() || "snapshot";
+ const safeName =
+ activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
+ "snapshot";
const filename = `${safeName}-${timecode}.png`;
- downloadBlob({ blob, filename });
- return { success: true };
+ return { success: true, blob, filename };
} catch (error) {
- console.error("Save snapshot failed:", error);
+ console.error("Snapshot capture failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
@@ -193,6 +235,8 @@ export class RendererManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts
index c844f448..b42a9b7c 100644
--- a/apps/web/src/core/managers/scenes-manager.ts
+++ b/apps/web/src/core/managers/scenes-manager.ts
@@ -1,5 +1,5 @@
import type { EditorCore } from "@/core";
-import type { TimelineTrack, TScene } from "@/types/timeline";
+import type { TimelineTrack, TScene } from "@/lib/timeline";
import { storageService } from "@/services/storage/service";
import {
getMainScene,
@@ -302,7 +302,9 @@ export class ScenesManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void {
diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts
index e4295c11..2062cfb0 100644
--- a/apps/web/src/core/managers/selection-manager.ts
+++ b/apps/web/src/core/managers/selection-manager.ts
@@ -1,7 +1,6 @@
import type { EditorCore } from "@/core";
-import type { SelectedKeyframeRef } from "@/types/animation";
-
-type ElementRef = { trackId: string; elementId: string };
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
+import type { ElementRef } from "@/lib/timeline/types";
export class SelectionManager {
private selectedElements: ElementRef[] = [];
@@ -67,6 +66,8 @@ export class SelectionManager {
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 173f0744..455222d6 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -1,16 +1,17 @@
import type { EditorCore } from "@/core";
-import type { EffectParamValues } from "@/types/effects";
+import type { ParamValues } from "@/lib/params";
import type {
TrackType,
TimelineTrack,
TimelineElement,
ClipboardItem,
-} from "@/types/timeline";
+ RetimeConfig,
+} from "@/lib/timeline";
import type {
+ AnimationPath,
AnimationInterpolation,
- AnimationPropertyPath,
AnimationValue,
-} from "@/types/animation";
+} from "@/lib/animation/types";
import { calculateTotalDuration } from "@/lib/timeline";
import {
AddTrackCommand,
@@ -30,6 +31,7 @@ import {
UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
+ UpdateElementRetimeCommand,
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
@@ -38,15 +40,18 @@ import {
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
ReorderClipEffectsCommand,
+ RemoveMaskCommand,
+ ToggleMaskInvertedCommand,
UpsertEffectParamKeyframeCommand,
RemoveEffectParamKeyframeCommand,
} from "@/lib/commands/timeline";
-import { BatchCommand, PreviewTracker } from "@/lib/commands";
+import { BatchCommand } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
private listeners = new Set<() => void>();
- private previewTracker = new PreviewTracker<TimelineTrack[]>();
+ private previewOverlay = new Map<string, Partial<TimelineElement>>();
+ private previewTracks: TimelineTrack[] | null = null;
constructor(private editor: EditorCore) {}
@@ -121,6 +126,29 @@ export class TimelineManager {
}
}
+ updateElementRetime({
+ trackId,
+ elementId,
+ retime,
+ pushHistory = true,
+ }: {
+ trackId: string;
+ elementId: string;
+ retime?: RetimeConfig;
+ pushHistory?: boolean;
+ }): void {
+ const command = new UpdateElementRetimeCommand({
+ trackId,
+ elementId,
+ retime,
+ });
+ if (pushHistory) {
+ this.editor.command.execute({ command });
+ } else {
+ command.execute();
+ }
+ }
+
updateElementStartTime({
elements,
startTime,
@@ -308,6 +336,23 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
+ removeMask({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }): void {
+ const command = new RemoveMaskCommand({
+ trackId,
+ elementId,
+ maskId,
+ });
+ this.editor.command.execute({ command });
+ }
+
updateClipEffectParams({
trackId,
elementId,
@@ -318,7 +363,7 @@ export class TimelineManager {
trackId: string;
elementId: string;
effectId: string;
- params: Partial<EffectParamValues>;
+ params: Partial<ParamValues>;
pushHistory?: boolean;
}): void {
const command = new UpdateClipEffectParamsCommand({
@@ -351,6 +396,23 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
+ toggleMaskInverted({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }): void {
+ const command = new ToggleMaskInvertedCommand({
+ trackId,
+ elementId,
+ maskId,
+ });
+ this.editor.command.execute({ command });
+ }
+
reorderClipEffects({
trackId,
elementId,
@@ -377,7 +439,7 @@ export class TimelineManager {
keyframes: Array<{
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
@@ -419,7 +481,7 @@ export class TimelineManager {
keyframes: Array<{
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}>;
}): void {
@@ -450,7 +512,7 @@ export class TimelineManager {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
time: number;
}): void {
@@ -520,7 +582,7 @@ export class TimelineManager {
}
isPreviewActive(): boolean {
- return this.previewTracker.isActive();
+ return this.previewOverlay.size > 0;
}
previewElements({
@@ -532,37 +594,51 @@ export class TimelineManager {
updates: Partial<TimelineElement>;
}>;
}): void {
- const tracks = this.getTracks();
- this.previewTracker.begin({ state: tracks });
-
- let updatedTracks = tracks;
- for (const { trackId, elementId, updates: elementUpdates } of updates) {
- updatedTracks = updatedTracks.map((track) => {
- if (track.id !== trackId) return track;
- const newElements = track.elements.map((element) =>
- element.id === elementId
- ? { ...element, ...elementUpdates }
- : element,
- );
- return { ...track, elements: newElements } as TimelineTrack;
- });
+ for (const { elementId, updates: elementUpdates } of updates) {
+ const existingOverlay = this.previewOverlay.get(elementId);
+ const mergedOverlay = {
+ ...existingOverlay,
+ ...elementUpdates,
+ } as Partial<TimelineElement>;
+ this.previewOverlay.set(elementId, mergedOverlay);
}
- this.updateTracks(updatedTracks);
+ const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
+ this.previewTracks = this.applyPreviewOverlay(committedTracks);
+ this.notify();
}
commitPreview(): void {
- const snapshot = this.previewTracker.end();
- if (snapshot === null) return;
- const currentTracks = this.getTracks();
- const command = new TracksSnapshotCommand(snapshot, currentTracks);
+ if (this.previewOverlay.size === 0) return;
+ const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
+ const afterTracks =
+ this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
+ const command = new TracksSnapshotCommand(committedTracks, afterTracks);
this.editor.command.push({ command });
+ this.previewOverlay.clear();
+ this.previewTracks = null;
+ this.updateTracks(afterTracks);
}
discardPreview(): void {
- const snapshot = this.previewTracker.end();
- if (snapshot !== null) {
- this.updateTracks(snapshot);
- }
+ if (this.previewOverlay.size === 0) return;
+ this.previewOverlay.clear();
+ this.previewTracks = null;
+ this.notify();
+ }
+
+ private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] {
+ if (this.previewOverlay.size === 0) return tracks;
+ return tracks.map((track) => {
+ const hasOverlay = track.elements.some((el) =>
+ this.previewOverlay.has(el.id),
+ );
+ if (!hasOverlay) return track;
+ const newElements = track.elements.map((el) => {
+ const overlay = this.previewOverlay.get(el.id);
+ return overlay ? ({ ...el, ...overlay } as TimelineElement) : el;
+ });
+ return { ...track, elements: newElements } as TimelineTrack;
+ });
}
duplicateElements({
@@ -597,16 +673,25 @@ export class TimelineManager {
return this.editor.scenes.getActiveScene()?.tracks ?? [];
}
+ getRenderTracks(): TimelineTrack[] {
+ if (this.previewTracks !== null) return this.previewTracks;
+ return this.getTracks();
+ }
+
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
- this.listeners.forEach((fn) => fn());
+ this.listeners.forEach((fn) => {
+ fn();
+ });
}
updateTracks(newTracks: TimelineTrack[]): void {
+ this.previewOverlay.clear();
+ this.previewTracks = null;
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
this.notify();
}
diff --git a/apps/web/src/data/colors/syntax-ui.tsx b/apps/web/src/data/colors/syntax-ui.tsx
index e650b7d2..67e6ab99 100644
--- a/apps/web/src/data/colors/syntax-ui.tsx
+++ b/apps/web/src/data/colors/syntax-ui.tsx
@@ -4,7 +4,6 @@ export const syntaxUIGradients = [
// Cyan to Blue gradients
"linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
"linear-gradient(to right, #bfdbfe, #a5f3fc)",
- "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
// Purple gradients
"linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)",
@@ -12,7 +11,6 @@ export const syntaxUIGradients = [
// Blue gradients
"linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
- "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
// Green gradients
"linear-gradient(to right, #6ee7b7, #34d399, #10b981)",
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
index e4f378d6..bfac931b 100644
--- a/apps/web/src/hooks/actions/use-editor-actions.ts
+++ b/apps/web/src/hooks/actions/use-editor-actions.ts
@@ -1,15 +1,22 @@
"use client";
+import { useEffect, useRef } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useActionHandler } from "@/hooks/actions/use-action-handler";
import { useEditor } from "../use-editor";
import { useElementSelection } from "../timeline/element/use-element-selection";
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
import { getElementsAtTime } from "@/lib/timeline";
+import { cancelInteraction } from "@/lib/cancel-interaction";
+import { invokeAction } from "@/lib/actions";
+import {
+ activateScope,
+ clearActiveScope,
+ type ScopeEntry,
+} from "@/lib/selection/scope";
export function useEditorActions() {
const editor = useEditor();
- const activeProject = editor.project.getActive();
const { selectedElements, setElementSelection } = useElementSelection();
const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection();
const clipboard = useTimelineStore((s) => s.clipboard);
@@ -17,6 +24,39 @@ export function useEditorActions() {
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
+ const hasTimelineSelectionRef = useRef(false);
+ const clearTimelineSelectionRef = useRef(() => {});
+ const timelineScopeRef = useRef<ScopeEntry | null>(null);
+ const hasTimelineSelection =
+ selectedElements.length > 0 || selectedKeyframes.length > 0;
+
+ hasTimelineSelectionRef.current = hasTimelineSelection;
+ clearTimelineSelectionRef.current = () => {
+ setElementSelection({ elements: [] });
+ clearKeyframeSelection();
+ };
+
+ if (!timelineScopeRef.current) {
+ timelineScopeRef.current = {
+ hasSelection: () => hasTimelineSelectionRef.current,
+ clear: () => {
+ clearTimelineSelectionRef.current();
+ },
+ };
+ }
+
+ useEffect(() => {
+ if (!hasTimelineSelection) {
+ return;
+ }
+
+ const timelineScope = timelineScopeRef.current;
+ if (!timelineScope) {
+ return;
+ }
+
+ return activateScope({ entry: timelineScope });
+ }, [hasTimelineSelection]);
useActionHandler(
"toggle-play",
@@ -65,7 +105,7 @@ export function useEditorActions() {
useActionHandler(
"frame-step-forward",
() => {
- const fps = activeProject.settings.fps;
+ const fps = editor.project.getActive().settings.fps;
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
@@ -79,7 +119,7 @@ export function useEditorActions() {
useActionHandler(
"frame-step-backward",
() => {
- const fps = activeProject.settings.fps;
+ const fps = editor.project.getActive().settings.fps;
editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps),
});
@@ -241,13 +281,21 @@ export function useEditorActions() {
);
useActionHandler(
+ "cancel-interaction",
+ () => {
+ if (!cancelInteraction()) {
+ invokeAction("deselect-all");
+ }
+ },
+ undefined,
+ );
+
+ useActionHandler(
"deselect-all",
() => {
- setElementSelection({ elements: [] });
- clearKeyframeSelection();
- const activeElement = document.activeElement;
- if (activeElement instanceof HTMLButtonElement) {
- activeElement.blur();
+ if (!clearActiveScope()) {
+ setElementSelection({ elements: [] });
+ clearKeyframeSelection();
}
},
undefined,
@@ -353,4 +401,29 @@ export function useEditorActions() {
},
undefined,
);
+
+ // todo: potnetially unify these two actions:
+ useActionHandler(
+ "remove-media-asset",
+ (args) => {
+ if (!args) return;
+ editor.media.removeMediaAsset({
+ projectId: args.projectId,
+ id: args.assetId,
+ });
+ },
+ undefined,
+ );
+
+ useActionHandler(
+ "remove-media-assets",
+ (args) => {
+ if (!args) return;
+ editor.media.removeMediaAssets({
+ projectId: args.projectId,
+ ids: args.assetIds,
+ });
+ },
+ undefined,
+ );
}
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index ea01c718..fae8c527 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -18,16 +18,14 @@ import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
-import {
- snapElementEdge,
- type SnapPoint,
-} from "@/lib/timeline/snap-utils";
+import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
+import { registerCanceller } from "@/lib/cancel-interaction";
import type {
DropTarget,
ElementDragState,
TimelineElement,
TimelineTrack,
-} from "@/types/timeline";
+} from "@/lib/timeline";
interface UseElementInteractionProps {
zoomLevel: number;
@@ -213,6 +211,20 @@ export function useElementInteraction({
setDragDropTarget(null);
}, []);
+ const cancelCurrentDrag = useCallback(() => {
+ pendingDragRef.current = null;
+ mouseDownLocationRef.current = null;
+ setIsPendingDrag(false);
+ endDrag();
+ onSnapPointChange?.(null);
+ }, [endDrag, onSnapPointChange]);
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ return registerCanceller({ fn: cancelCurrentDrag });
+ }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
+
const getDragSnapResult = useCallback(
({
frameSnappedTime,
@@ -445,33 +457,36 @@ export function useElementInteraction({
return;
}
- if (dropTarget.isNewTrack) {
- const newTrackId = generateUUID();
+ if (dropTarget.isNewTrack) {
+ const newTrackId = generateUUID();
- editor.timeline.moveElement({
- sourceTrackId: dragState.trackId,
- targetTrackId: newTrackId,
- elementId: dragState.elementId,
- newStartTime: snappedTime,
- createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
- rippleEnabled: rippleEditingEnabled,
- });
- selectElement({ trackId: newTrackId, elementId: dragState.elementId });
- } else {
- const targetTrack = tracks[dropTarget.trackIndex];
- if (targetTrack) {
editor.timeline.moveElement({
sourceTrackId: dragState.trackId,
- targetTrackId: targetTrack.id,
+ targetTrackId: newTrackId,
elementId: dragState.elementId,
newStartTime: snappedTime,
+ createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
rippleEnabled: rippleEditingEnabled,
});
- if (targetTrack.id !== dragState.trackId) {
- selectElement({ trackId: targetTrack.id, elementId: dragState.elementId });
+ selectElement({ trackId: newTrackId, elementId: dragState.elementId });
+ } else {
+ const targetTrack = tracks[dropTarget.trackIndex];
+ if (targetTrack) {
+ editor.timeline.moveElement({
+ sourceTrackId: dragState.trackId,
+ targetTrackId: targetTrack.id,
+ elementId: dragState.elementId,
+ newStartTime: snappedTime,
+ rippleEnabled: rippleEditingEnabled,
+ });
+ if (targetTrack.id !== dragState.trackId) {
+ selectElement({
+ trackId: targetTrack.id,
+ elementId: dragState.elementId,
+ });
+ }
}
}
- }
endDrag();
onSnapPointChange?.(null);
@@ -520,10 +535,10 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
- const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
+ const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
- // right-click: don't stop propagation so ContextMenu can open
- if (isRightClick) {
+ // right-click: don't stop propagation so ContextMenu can open
+ if (isRightClick) {
const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
@@ -538,12 +553,12 @@ export function useElementInteraction({
return;
}
- event.stopPropagation();
- mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
+ event.stopPropagation();
+ mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
- const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
+ const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
- if (isMultiSelect) {
+ if (isMultiSelect) {
handleSelectionClick({
trackId: track.id,
elementId: element.id,
@@ -551,7 +566,7 @@ export function useElementInteraction({
});
}
- const clickOffsetTime = getClickOffsetTime({
+ const clickOffsetTime = getClickOffsetTime({
clientX: event.clientX,
elementRect: event.currentTarget.getBoundingClientRect(),
zoomLevel,
@@ -579,9 +594,9 @@ export function useElementInteraction({
element: TimelineElement;
track: TimelineTrack;
}) => {
- event.stopPropagation();
+ event.stopPropagation();
- if (mouseDownLocationRef.current) {
+ if (mouseDownLocationRef.current) {
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
@@ -593,7 +608,7 @@ export function useElementInteraction({
// modifier keys already handled in mousedown
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
- const alreadySelected = isElementSelected({
+ const alreadySelected = isElementSelected({
trackId: track.id,
elementId: element.id,
});
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 3f49e798..b41f82f9 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
@@ -9,7 +9,13 @@ import {
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
+import { isRetimableElement } from "@/lib/timeline";
+import {
+ getSourceSpanAtClipTime,
+ getTimelineDurationForSourceSpan,
+} from "@/lib/retime";
import { useTimelineStore } from "@/stores/timeline-store";
+import { registerCanceller } from "@/lib/cancel-interaction";
export interface ResizeState {
elementId: string;
@@ -37,7 +43,6 @@ export function useTimelineElementResize({
onResizeStateChange,
}: UseTimelineElementResizeProps) {
const editor = useEditor();
- const activeProject = editor.project.getActive();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const rippleEditingEnabled = useTimelineStore(
@@ -91,6 +96,94 @@ export function useTimelineElementResize({
return element.sourceDuration == null;
}, [element.sourceDuration]);
+ const getSourceDeltaForClipDelta = useCallback(
+ (clipDelta: number) => {
+ if (!isRetimableElement(element)) {
+ return clipDelta;
+ }
+
+ return clipDelta >= 0
+ ? getSourceSpanAtClipTime({
+ clipTime: clipDelta,
+ retime: element.retime,
+ })
+ : -getSourceSpanAtClipTime({
+ clipTime: Math.abs(clipDelta),
+ retime: element.retime,
+ });
+ },
+ [element],
+ );
+
+ const getVisibleSourceSpanForDuration = useCallback(
+ (duration: number) => {
+ if (!isRetimableElement(element)) {
+ return duration;
+ }
+
+ return getSourceSpanAtClipTime({
+ clipTime: duration,
+ retime: element.retime,
+ });
+ },
+ [element],
+ );
+
+ const getDurationForVisibleSourceSpan = useCallback(
+ (sourceSpan: number) => {
+ if (!isRetimableElement(element)) {
+ return sourceSpan;
+ }
+
+ return getTimelineDurationForSourceSpan({
+ sourceSpan,
+ retime: element.retime,
+ });
+ },
+ [element],
+ );
+
+ const getSourceDuration = useCallback(
+ ({
+ trimStart,
+ duration,
+ trimEnd,
+ }: {
+ trimStart: number;
+ duration: number;
+ trimEnd: number;
+ }) => {
+ if (typeof element.sourceDuration === "number") {
+ return element.sourceDuration;
+ }
+
+ return trimStart + getVisibleSourceSpanForDuration(duration) + trimEnd;
+ },
+ [element.sourceDuration, getVisibleSourceSpanForDuration],
+ );
+
+ const cancelResize = useCallback(() => {
+ if (!resizing) return;
+
+ setCurrentTrimStart(resizing.initialTrimStart);
+ setCurrentTrimEnd(resizing.initialTrimEnd);
+ setCurrentStartTime(resizing.initialStartTime);
+ setCurrentDuration(resizing.initialDuration);
+ currentTrimStartRef.current = resizing.initialTrimStart;
+ currentTrimEndRef.current = resizing.initialTrimEnd;
+ currentStartTimeRef.current = resizing.initialStartTime;
+ currentDurationRef.current = resizing.initialDuration;
+ setResizing(null);
+ onResizeStateChange?.({ isResizing: false });
+ onSnapPointChange?.(null);
+ }, [resizing, onResizeStateChange, onSnapPointChange]);
+
+ useEffect(() => {
+ if (!resizing) return;
+
+ return registerCanceller({ fn: cancelResize });
+ }, [resizing, cancelResize]);
+
const updateTrimFromMouseMove = useCallback(
({ clientX }: { clientX: number }) => {
if (!resizing) return;
@@ -100,7 +193,7 @@ export function useTimelineElementResize({
deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
let resizeSnapPoint: SnapPoint | null = null;
- const projectFps = activeProject.settings.fps;
+ const projectFps = editor.project.getActive().settings.fps;
const minDurationSeconds = 1 / projectFps;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) {
@@ -139,14 +232,20 @@ export function useTimelineElementResize({
}
onSnapPointChange?.(resizeSnapPoint);
- const otherElements = track.elements.filter(({ id }) => id !== element.id);
- const initialEndTime = resizing.initialStartTime + resizing.initialDuration;
+ const otherElements = track.elements.filter(
+ ({ id }) => id !== element.id,
+ );
+ const initialEndTime =
+ resizing.initialStartTime + resizing.initialDuration;
const rightNeighborBound =
resizing.side === "right"
? otherElements
.filter(({ startTime }) => startTime >= initialEndTime)
- .reduce((min, { startTime }) => Math.min(min, startTime), Infinity)
+ .reduce(
+ (min, { startTime }) => Math.min(min, startTime),
+ Infinity,
+ )
: Infinity;
const leftNeighborBound =
@@ -164,20 +263,26 @@ export function useTimelineElementResize({
: -Infinity;
if (resizing.side === "left") {
- const sourceDuration =
- resizing.initialTrimStart +
- resizing.initialDuration +
- resizing.initialTrimEnd;
+ const sourceDuration = getSourceDuration({
+ trimStart: resizing.initialTrimStart,
+ duration: resizing.initialDuration,
+ trimEnd: resizing.initialTrimEnd,
+ });
const minTrimStartForNeighbor = Number.isFinite(leftNeighborBound)
? Math.max(
0,
resizing.initialTrimStart +
- (leftNeighborBound - resizing.initialStartTime),
+ getSourceDeltaForClipDelta(
+ leftNeighborBound - resizing.initialStartTime,
+ ),
)
: 0;
const maxAllowed =
- sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
- const calculated = resizing.initialTrimStart + deltaTime;
+ sourceDuration -
+ resizing.initialTrimEnd -
+ getVisibleSourceSpanForDuration(minDurationSeconds);
+ const calculated =
+ resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
if (calculated >= 0 && calculated <= maxAllowed) {
const newTrimStart = snapTimeToFrame({
@@ -187,13 +292,17 @@ export function useTimelineElementResize({
),
fps: projectFps,
});
- const trimDelta = newTrimStart - resizing.initialTrimStart;
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime + trimDelta,
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - newTrimStart - resizing.initialTrimEnd,
+ );
+ const newDuration = snapTimeToFrame({
+ time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
+ const trimDelta = resizing.initialDuration - newDuration;
+ const newStartTime = snapTimeToFrame({
+ time: resizing.initialStartTime + trimDelta,
fps: projectFps,
});
@@ -233,31 +342,50 @@ export function useTimelineElementResize({
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
} else {
- const trimDelta =
+ const leftBound = Number.isFinite(leftNeighborBound)
+ ? leftNeighborBound
+ : 0;
+ const trimDeltaFromTrimStart =
minTrimStartForNeighbor - resizing.initialTrimStart;
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime + trimDelta,
+ const trimDeltaFromStartTime = getSourceDeltaForClipDelta(
+ leftBound - resizing.initialStartTime,
+ );
+ const trimDelta = Math.max(
+ trimDeltaFromTrimStart,
+ trimDeltaFromStartTime,
+ );
+ const newTrimStart = resizing.initialTrimStart + trimDelta;
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - newTrimStart - resizing.initialTrimEnd,
+ );
+ const newDuration = snapTimeToFrame({
+ time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
+ const newStartTime = snapTimeToFrame({
+ time:
+ resizing.initialStartTime +
+ (resizing.initialDuration - newDuration),
fps: projectFps,
});
- setCurrentTrimStart(minTrimStartForNeighbor);
+ setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
- currentTrimStartRef.current = minTrimStartForNeighbor;
+ currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
}
}
} else {
- const sourceDuration =
- resizing.initialTrimStart +
- resizing.initialDuration +
- resizing.initialTrimEnd;
- const newTrimEnd = resizing.initialTrimEnd - deltaTime;
+ const sourceDuration = getSourceDuration({
+ trimStart: resizing.initialTrimStart,
+ duration: resizing.initialDuration,
+ trimEnd: resizing.initialTrimEnd,
+ });
+ const newTrimEnd =
+ resizing.initialTrimEnd - getSourceDeltaForClipDelta(deltaTime);
const maxAllowedDuration = Number.isFinite(rightNeighborBound)
? rightNeighborBound - resizing.initialStartTime
: Infinity;
@@ -268,7 +396,10 @@ export function useTimelineElementResize({
const baseDuration =
resizing.initialDuration + resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
- time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration),
+ time: Math.min(
+ baseDuration + extensionNeeded,
+ maxAllowedDuration,
+ ),
fps: projectFps,
});
@@ -277,12 +408,11 @@ export function useTimelineElementResize({
currentDurationRef.current = newDuration;
currentTrimEndRef.current = 0;
} else {
- const extensionToLimit = resizing.initialTrimEnd;
+ const unclampedDuration = getDurationForVisibleSourceSpan(
+ Math.max(0, sourceDuration - resizing.initialTrimStart),
+ );
const newDuration = snapTimeToFrame({
- time: Math.min(
- resizing.initialDuration + extensionToLimit,
- maxAllowedDuration,
- ),
+ time: Math.min(unclampedDuration, maxAllowedDuration),
fps: projectFps,
});
@@ -295,13 +425,15 @@ export function useTimelineElementResize({
const minTrimEndForNeighbor = Number.isFinite(maxAllowedDuration)
? Math.max(
0,
- resizing.initialDuration +
- resizing.initialTrimEnd -
- maxAllowedDuration,
+ sourceDuration -
+ resizing.initialTrimStart -
+ getVisibleSourceSpanForDuration(maxAllowedDuration),
)
: 0;
const maxTrimEnd =
- sourceDuration - resizing.initialTrimStart - minDurationSeconds;
+ sourceDuration -
+ resizing.initialTrimStart -
+ getVisibleSourceSpanForDuration(minDurationSeconds);
const clampedTrimEnd = Math.min(
maxTrimEnd,
Math.max(minTrimEndForNeighbor, newTrimEnd),
@@ -310,9 +442,12 @@ export function useTimelineElementResize({
time: clampedTrimEnd,
fps: projectFps,
});
- const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - resizing.initialTrimStart - finalTrimEnd,
+ );
const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
+ time: getDurationForVisibleSourceSpan(visibleSourceSpan),
fps: projectFps,
});
@@ -326,13 +461,16 @@ export function useTimelineElementResize({
[
resizing,
zoomLevel,
- activeProject.settings.fps,
snappingEnabled,
editor,
element.id,
track.elements,
onSnapPointChange,
canExtendElementDuration,
+ getDurationForVisibleSourceSpan,
+ getSourceDeltaForClipDelta,
+ getSourceDuration,
+ getVisibleSourceSpanForDuration,
isShiftHeldRef,
],
);
@@ -349,7 +487,12 @@ export function useTimelineElementResize({
const startTimeChanged = finalStartTime !== resizing.initialStartTime;
const durationChanged = finalDuration !== resizing.initialDuration;
- if (trimStartChanged || trimEndChanged || startTimeChanged || durationChanged) {
+ if (
+ trimStartChanged ||
+ trimEndChanged ||
+ startTimeChanged ||
+ durationChanged
+ ) {
editor.timeline.updateElementTrim({
elementId: element.id,
trimStart: finalTrimStart,
diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts
index c1e5b53f..dcfb5720 100644
--- a/apps/web/src/hooks/timeline/element/use-element-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts
@@ -1,14 +1,10 @@
-import { useCallback, useSyncExternalStore } from "react";
+import { useCallback } from "react";
import { useEditor } from "@/hooks/use-editor";
-
-type ElementRef = { trackId: string; elementId: string };
+import type { ElementRef } from "@/lib/timeline/types";
export function useElementSelection() {
const editor = useEditor();
- const selectedElements = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedElements(),
- );
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
const isElementSelected = useCallback(
({ trackId, elementId }: ElementRef) =>
@@ -82,6 +78,30 @@ export function useElementSelection() {
[editor],
);
+
+ /**
+ * Merges elements into the current selection, deduplicating by identity.
+ * Used for additive box-select where the pre-drag selection is preserved.
+ */
+ const mergeElementsIntoSelection = useCallback(
+ ({ elements }: { elements: ElementRef[] }) => {
+ const merged = [
+ ...selectedElements.filter(
+ (selectedElement) =>
+ !elements.some(
+ (element) =>
+ element.trackId === selectedElement.trackId &&
+ element.elementId === selectedElement.elementId,
+ ),
+ ),
+ ...elements,
+ ];
+ editor.selection.setSelectedElements({ elements: merged });
+ },
+ [selectedElements, editor],
+ );
+
+
/**
* Handles click interaction on an element.
* - Regular click: select only this element
@@ -107,6 +127,7 @@ export function useElementSelection() {
isElementSelected,
selectElement,
setElementSelection,
+ mergeElementsIntoSelection,
addElementToSelection,
removeElementFromSelection,
toggleElementSelection,
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
index b18db6eb..4f961a0a 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts
@@ -15,9 +15,10 @@ import {
} from "@/constants/timeline-constants";
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/lib/commands";
-import type { SelectedKeyframeRef } from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
import type { Command } from "@/lib/commands/base-command";
+import { registerCanceller } from "@/lib/cancel-interaction";
export interface KeyframeDragState {
isDragging: boolean;
draggingKeyframeIds: Set<string>;
@@ -69,6 +70,13 @@ export function useKeyframeDrag({
setDragState(initialDragState);
}, []);
+ const cancelDrag = useCallback(() => {
+ pendingDragRef.current = null;
+ mouseDownXRef.current = null;
+ setIsPendingDrag(false);
+ endDrag();
+ }, [endDrag]);
+
const commitDrag = useCallback(
({
keyframeRefs,
@@ -110,6 +118,12 @@ export function useKeyframeDrag({
useEffect(() => {
if (!dragState.isDragging && !isPendingDrag) return;
+ return registerCanceller({ fn: cancelDrag });
+ }, [dragState.isDragging, isPendingDrag, cancelDrag]);
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
const handleMouseMove = ({ clientX }: MouseEvent) => {
if (isPendingDrag && pendingDragRef.current) {
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
index 0ab05fe3..8634cb48 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
@@ -1,6 +1,6 @@
import { useCallback, useSyncExternalStore } from "react";
import { useEditor } from "@/hooks/use-editor";
-import type { SelectedKeyframeRef } from "@/types/animation";
+import type { SelectedKeyframeRef } from "@/lib/animation/types";
function getSelectedKeyframeId({
keyframe,
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
index 0e7fabd5..512db821 100644
--- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -15,7 +15,7 @@ import {
snapToNearestPoint,
type SnapPoint,
} from "@/lib/timeline/snap-utils";
-import type { Bookmark } from "@/types/timeline";
+import type { Bookmark } from "@/lib/timeline";
export interface BookmarkDragState {
isDragging: boolean;
diff --git a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
new file mode 100644
index 00000000..50833f14
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts
@@ -0,0 +1,42 @@
+import { type RefObject, useLayoutEffect, useRef } from "react";
+
+interface UseInitialScrollBottomProps {
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef: RefObject<HTMLDivElement | null>;
+ onAfterScroll?: () => void;
+ /** Defers the scroll until there is at least one track to measure against. */
+ isReady: boolean;
+}
+
+/**
+ * Scrolls the timeline tracks viewport to the bottom once on mount.
+ * useLayoutEffect runs synchronously after React commits the DOM but before
+ * the browser paints, so the initial scroll position is never visible.
+ */
+export function useInitialScrollBottom({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ onAfterScroll,
+ isReady,
+}: UseInitialScrollBottomProps): void {
+ const hasScrolledRef = useRef(false);
+
+ useLayoutEffect(() => {
+ if (!isReady || hasScrolledRef.current) return;
+
+ const viewport = tracksScrollRef.current;
+ if (!viewport) return;
+
+ const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
+ if (maxScrollTop <= 0) return;
+
+ viewport.scrollTop = maxScrollTop;
+
+ if (trackLabelsScrollRef.current) {
+ trackLabelsScrollRef.current.scrollTop = maxScrollTop;
+ }
+
+ onAfterScroll?.();
+ hasScrolledRef.current = true;
+ }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]);
+}
diff --git a/apps/web/src/hooks/timeline/use-selection-box.ts b/apps/web/src/hooks/timeline/use-selection-box.ts
deleted file mode 100644
index c4f8d6ce..00000000
--- a/apps/web/src/hooks/timeline/use-selection-box.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
-import { useEditor } from "../use-editor";
-
-interface UseSelectionBoxProps {
- containerRef: React.RefObject<HTMLElement | null>;
- headerRef: React.RefObject<HTMLElement | null>;
- onSelectionComplete: (
- elements: { trackId: string; elementId: string }[],
- ) => void;
- isEnabled?: boolean;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- zoomLevel: number;
-}
-
-interface SelectionBoxState {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- isActive: boolean;
-}
-
-interface SelectionRectangle {
- left: number;
- top: number;
- right: number;
- bottom: number;
-}
-
-function getNormalizedRectangle({
- startPos,
- endPos,
-}: {
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
-}): SelectionRectangle {
- return {
- left: Math.min(startPos.x, endPos.x),
- top: Math.min(startPos.y, endPos.y),
- right: Math.max(startPos.x, endPos.x),
- bottom: Math.max(startPos.y, endPos.y),
- };
-}
-
-function getSelectionRectangleInContent({
- container,
- scrollContainer,
- startPos,
- endPos,
-}: {
- container: HTMLElement;
- scrollContainer: HTMLDivElement | null;
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
-}): SelectionRectangle {
- const containerRect = container.getBoundingClientRect();
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
-
- const adjustedStart = {
- x: startPos.x - containerRect.left + scrollLeft,
- y: startPos.y - containerRect.top + scrollTop,
- };
- const adjustedEnd = {
- x: endPos.x - containerRect.left + scrollLeft,
- y: endPos.y - containerRect.top + scrollTop,
- };
-
- return getNormalizedRectangle({
- startPos: adjustedStart,
- endPos: adjustedEnd,
- });
-}
-
-function isRectangleIntersecting({
- elementRectangle,
- selectionRectangle,
-}: {
- elementRectangle: SelectionRectangle;
- selectionRectangle: SelectionRectangle;
-}): boolean {
- return !(
- elementRectangle.right < selectionRectangle.left ||
- elementRectangle.left > selectionRectangle.right ||
- elementRectangle.bottom < selectionRectangle.top ||
- elementRectangle.top > selectionRectangle.bottom
- );
-}
-
-export function useSelectionBox({
- containerRef,
- headerRef,
- onSelectionComplete,
- isEnabled = true,
- tracksScrollRef,
- zoomLevel,
-}: UseSelectionBoxProps) {
- const editor = useEditor();
- const tracks = editor.timeline.getTracks();
- const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
- null,
- );
- const justFinishedSelectingRef = useRef(false);
-
- const handleMouseDown = useCallback(
- ({ clientX, clientY }: React.MouseEvent) => {
- if (!isEnabled) return;
-
- setSelectionBox({
- startPos: { x: clientX, y: clientY },
- currentPos: { x: clientX, y: clientY },
- isActive: false,
- });
- },
- [isEnabled],
- );
-
- const selectElementsInBox = useCallback(
- ({
- startPos,
- endPos,
- }: {
- startPos: { x: number; y: number };
- endPos: { x: number; y: number };
- }) => {
- if (!containerRef.current) return;
-
- const container = containerRef.current;
- const selectionRectangle = getSelectionRectangleInContent({
- container,
- scrollContainer: tracksScrollRef.current,
- startPos,
- endPos,
- });
- const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const timelineHeaderHeight =
- headerRef.current?.getBoundingClientRect().height ?? 0;
- const selectedElements: { trackId: string; elementId: string }[] = [];
-
- for (const [trackIndex, track] of tracks.entries()) {
- const trackTop = getCumulativeHeightBefore({
- tracks,
- trackIndex,
- });
- const trackHeight = getTrackHeight({ type: track.type });
- const elementTop =
- timelineHeaderHeight + TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
- const elementBottom = elementTop + trackHeight;
-
- for (const element of track.elements) {
- const elementLeft = element.startTime * pixelsPerSecond;
- const elementRight = elementLeft + element.duration * pixelsPerSecond;
-
- const elementRectangle = {
- left: elementLeft,
- top: elementTop,
- right: elementRight,
- bottom: elementBottom,
- };
-
- const intersects = isRectangleIntersecting({
- elementRectangle,
- selectionRectangle,
- });
-
- if (intersects) {
- selectedElements.push({
- trackId: track.id,
- elementId: element.id,
- });
- }
- }
- }
- onSelectionComplete(selectedElements);
- },
- [
- containerRef,
- headerRef,
- onSelectionComplete,
- tracks,
- tracksScrollRef,
- zoomLevel,
- ],
- );
-
- 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 shouldActivate = deltaX > 5 || deltaY > 5;
-
- const newSelectionBox = {
- ...selectionBox,
- currentPos: { x: clientX, y: clientY },
- isActive: shouldActivate || selectionBox.isActive,
- };
-
- setSelectionBox(newSelectionBox);
-
- if (newSelectionBox.isActive) {
- selectElementsInBox({
- startPos: newSelectionBox.startPos,
- endPos: newSelectionBox.currentPos,
- });
- }
- };
-
- 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, selectElementsInBox]);
-
- useEffect(() => {
- if (!selectionBox) return;
-
- const previousBodyUserSelect = document.body.style.userSelect;
- const container = containerRef.current;
- const previousContainerUserSelect = container?.style.userSelect ?? "";
-
- document.body.style.userSelect = "none";
- if (container) container.style.userSelect = "none";
-
- return () => {
- document.body.style.userSelect = previousBodyUserSelect;
- if (container) container.style.userSelect = previousContainerUserSelect;
- };
- }, [selectionBox, containerRef]);
-
- const shouldIgnoreClick = useCallback(() => {
- return justFinishedSelectingRef.current;
- }, []);
-
- return {
- selectionBox,
- handleMouseDown,
- isSelecting: selectionBox?.isActive || false,
- shouldIgnoreClick,
- };
-}
diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
index f3e63894..1a8f3c5d 100644
--- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
+++ b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts
@@ -1,13 +1,10 @@
import { useEffect, useState } from "react";
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import type { TimelineTrack } from "@/types/timeline";
-
+import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
- tracks: TimelineTrack[];
timelineRef: React.RefObject<HTMLDivElement | null>;
- trackLabelsRef?: React.RefObject<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
}
@@ -20,9 +17,7 @@ interface SnapIndicatorPosition {
export function useSnapIndicatorPosition({
snapPoint,
zoomLevel,
- tracks,
timelineRef,
- trackLabelsRef,
tracksScrollRef,
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
const [scrollLeft, setScrollLeft] = useState(0);
@@ -45,16 +40,11 @@ export function useSnapIndicatorPosition({
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
- const trackLabelsWidth =
- tracks.length > 0 && trackLabelsRef?.current
- ? trackLabelsRef.current.offsetWidth
- : 0;
-
const timelinePosition = timelineTimeToSnappedPixels({
time: snapPoint?.time ?? 0,
zoomLevel,
});
- const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
+ const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft;
return {
leftPosition,
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index 8a30785e..e8f6e49d 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -2,26 +2,28 @@ import { useState, useCallback, type RefObject } from "react";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
import { toast } from "sonner";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import {
buildTextElement,
+ buildGraphicElement,
buildStickerElement,
buildElementFromMedia,
buildEffectElement,
} from "@/lib/timeline/element-utils";
-import type { Command } from "@/lib/commands/base-command";
-import { AddMediaAssetCommand } from "@/lib/commands/media";
import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data";
-import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
+import { isMainTrack } from "@/lib/timeline/track-utils";
+import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
import type {
MediaDragData,
+ GraphicDragData,
StickerDragData,
EffectDragData,
-} from "@/types/drag";
+} from "@/lib/timeline/drag";
interface UseTimelineDragDropProps {
containerRef: RefObject<HTMLDivElement | null>;
@@ -41,17 +43,12 @@ export function useTimelineDragDrop({
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [dragElementType, setElementType] = useState<ElementType | null>(null);
- const tracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
- const mediaAssets = editor.media.getAssets();
- const activeProject = editor.project.getActive();
-
const getSnappedTime = useCallback(
({ time }: { time: number }) => {
- const projectFps = activeProject.settings.fps;
+ const projectFps = editor.project.getActive().settings.fps;
return snapTimeToFrame({ time, fps: projectFps });
},
- [activeProject.settings.fps],
+ [editor],
);
const getElementType = useCallback(
@@ -60,6 +57,7 @@ export function useTimelineDragDrop({
if (!dragData) return null;
if (dragData.type === "text") return "text";
+ if (dragData.type === "graphic") return "graphic";
if (dragData.type === "sticker") return "sticker";
if (dragData.type === "effect") return "effect";
if (dragData.type === "media") {
@@ -80,18 +78,20 @@ export function useTimelineDragDrop({
}): number => {
if (
elementType === "text" ||
+ elementType === "graphic" ||
elementType === "sticker" ||
elementType === "effect"
) {
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
if (mediaId) {
+ const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId);
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
}
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
},
- [mediaAssets],
+ [editor],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
@@ -148,6 +148,8 @@ export function useTimelineDragDrop({
? (dragData as MediaDragData).targetElementTypes
: undefined;
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
const target = computeDropTarget({
elementType,
mouseX,
@@ -170,12 +172,11 @@ export function useTimelineDragDrop({
containerRef,
headerRef,
tracksScrollRef,
- tracks,
- currentTime,
zoomLevel,
getElementType,
getElementDuration,
getSnappedTime,
+ editor,
],
);
@@ -228,6 +229,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@@ -235,7 +237,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, tracks],
+ [editor],
);
const executeStickerDrop = useCallback(
@@ -253,7 +255,45 @@ export function useTimelineDragDrop({
});
if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand("sticker", target.trackIndex);
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = editor.timeline.getTracks();
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeGraphicDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: GraphicDragData;
+ }) => {
+ const element = buildGraphicElement({
+ definitionId: dragData.definitionId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ params: dragData.params,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
const insertCmd = new InsertElementCommand({
element,
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
@@ -264,6 +304,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@@ -271,7 +312,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, tracks],
+ [editor],
);
const executeMediaDrop = useCallback(
@@ -281,6 +322,7 @@ export function useTimelineDragDrop({
return;
}
+ const mediaAssets = editor.media.getAssets();
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
if (!mediaAsset) return;
@@ -309,6 +351,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const track = tracks[target.trackIndex];
if (!track) return;
editor.timeline.insertElement({
@@ -316,7 +359,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, mediaAssets, tracks],
+ [editor],
);
const executeEffectDrop = useCallback(
@@ -336,6 +379,7 @@ export function useTimelineDragDrop({
return;
}
+ const tracks = editor.timeline.getTracks();
const effectTrack = tracks.find((t) => t.type === "effect");
let trackId: string;
@@ -370,7 +414,7 @@ export function useTimelineDragDrop({
element,
});
},
- [editor.command, editor.timeline, tracks],
+ [editor],
);
const executeFileDrop = useCallback(
@@ -383,70 +427,98 @@ export function useTimelineDragDrop({
mouseX: number;
mouseY: number;
}) => {
+ const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return;
- const processedAssets = await processMediaAssets({ files });
- const projectId = activeProject.metadata.id;
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+ const projectId = activeProject.metadata.id;
- for (const asset of processedAssets) {
- const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const currentTracks = editor.timeline.getTracks();
- const dropTarget = computeDropTarget({
- elementType: asset.type,
- mouseX,
- mouseY,
- tracks: currentTracks,
- playheadTime: currentTime,
- isExternalDrop: true,
- elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
- zoomLevel,
- });
+ for (const asset of processedAssets) {
+ const createdAsset = await editor.media.addMediaAsset({
+ projectId,
+ asset,
+ });
+ if (!createdAsset) continue;
- const trackType: TrackType = asset.type === "audio" ? "audio" : "video";
- const addMediaCmd = new AddMediaAssetCommand(projectId, asset);
- const assetId = addMediaCmd.getAssetId();
+ const duration =
+ createdAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const currentTracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const onlyTrack = currentTracks[0];
+ const reuseMainTrackId =
+ createdAsset.type !== "audio" &&
+ currentTracks.length === 1 &&
+ !!onlyTrack &&
+ isMainTrack(onlyTrack) &&
+ onlyTrack.elements.length === 0
+ ? onlyTrack.id
+ : null;
+ const dropTarget = reuseMainTrackId
+ ? null
+ : computeDropTarget({
+ elementType: createdAsset.type,
+ mouseX,
+ mouseY,
+ tracks: currentTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
+ });
- const commands: Command[] = [addMediaCmd];
+ const trackType: TrackType =
+ createdAsset.type === "audio" ? "audio" : "video";
- let trackId: string | undefined;
- if (dropTarget.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(
- trackType,
- dropTarget.trackIndex,
- );
- trackId = addTrackCmd.getTrackId();
- commands.unshift(addTrackCmd);
- } else {
- trackId = currentTracks[dropTarget.trackIndex]?.id;
- }
+ let trackId: string | undefined;
+ if (reuseMainTrackId) {
+ trackId = reuseMainTrackId;
+ } else {
+ if (!dropTarget) continue;
+ if (dropTarget.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(
+ trackType,
+ dropTarget.trackIndex,
+ );
+ trackId = addTrackCmd.getTrackId();
+ editor.command.execute({ command: addTrackCmd });
+ } else {
+ trackId = currentTracks[dropTarget.trackIndex]?.id;
+ }
+ }
- if (!trackId) return;
+ if (!trackId) continue;
- const element = buildElementFromMedia({
- mediaId: assetId,
- mediaType: asset.type,
- name: asset.name,
- duration,
- startTime: dropTarget.xPosition,
- buffer:
- asset.type === "audio"
- ? new AudioBuffer({ length: 1, sampleRate: 44100 })
- : undefined,
- });
+ const element = buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: dropTarget?.xPosition ?? currentTime,
+ buffer:
+ createdAsset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId },
- });
- commands.push(insertCmd);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId },
+ });
+ editor.command.execute({ command: insertCmd });
+ }
- const batchCmd = new BatchCommand(commands);
- editor.command.execute({ command: batchCmd });
- }
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
},
- [activeProject, editor.command, editor.timeline, currentTime, zoomLevel],
+ [editor, zoomLevel],
);
const handleDrop = useCallback(
@@ -471,6 +543,11 @@ export function useTimelineDragDrop({
if (dragData.type === "text") {
executeTextDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "graphic") {
+ executeGraphicDrop({
+ target: currentTarget,
+ dragData: dragData as GraphicDragData,
+ });
} else if (dragData.type === "sticker") {
executeStickerDrop({ target: currentTarget, dragData });
} else if (dragData.type === "effect") {
@@ -502,12 +579,12 @@ export function useTimelineDragDrop({
}
} catch (err) {
console.error("Failed to process drop:", err);
- toast.error("Failed to process drop");
}
},
[
dropTarget,
executeTextDrop,
+ executeGraphicDrop,
executeStickerDrop,
executeMediaDrop,
executeEffectDrop,
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index 1426a003..e6204f23 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -1,12 +1,13 @@
import { getSnappedSeekTime } from "@/lib/time";
-import { useState, useEffect, useCallback, useRef } from "react";
+import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
+import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
import {
- findSnapPoints,
- snapToNearestPoint,
-} from "@/lib/timeline/snap-utils";
+ getCenteredLineLeft,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
@@ -25,27 +26,33 @@ export function useTimelinePlayhead({
playheadRef,
}: UseTimelinePlayheadProps) {
const editor = useEditor();
+ const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
const activeProject = editor.project.getActive();
- const currentTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration();
- const isPlaying = editor.playback.getIsPlaying();
- const isScrubbing = editor.playback.getIsScrubbing();
const isShiftHeldRef = useShiftKey();
+ const zoomLevelRef = useRef(zoomLevel);
+ const durationRef = useRef(duration);
+ const isScrubbingRef = useRef(isScrubbing);
+ const isPlayingRef = useRef(false);
+
+ useEffect(() => {
+ zoomLevelRef.current = zoomLevel;
+ durationRef.current = duration;
+ isScrubbingRef.current = isScrubbing;
+ isPlayingRef.current = editor.playback.getIsPlaying();
+ }, [zoomLevel, duration, isScrubbing, editor.playback]);
+
const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }),
[editor.playback],
);
- const [scrubTime, setScrubTime] = useState<number | null>(null);
-
- const [isDraggingRuler, setIsDraggingRuler] = useState(false);
- const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
+ const scrubTimeRef = useRef<number | null>(null);
+ const isDraggingRulerRef = useRef(false);
+ const hasDraggedRulerRef = useRef(false);
const lastMouseXRef = useRef<number>(0);
- const playheadPosition =
- isScrubbing && scrubTime !== null ? scrubTime : currentTime;
-
const handleScrub = useCallback(
({
event,
@@ -86,8 +93,7 @@ export function useTimelinePlayhead({
const time = (() => {
if (!shouldSnap) return frameTime;
const tracks = editor.timeline.getTracks();
- const bookmarks =
- editor.scenes.getActiveScene()?.bookmarks ?? [];
+ const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
const snapPoints = findSnapPoints({
tracks,
playheadTime: frameTime,
@@ -102,7 +108,7 @@ export function useTimelinePlayhead({
return snapResult.snapPoint ? snapResult.snappedTime : frameTime;
})();
- setScrubTime(time);
+ scrubTimeRef.current = time;
seek({ time });
lastMouseXRef.current = event.clientX;
@@ -132,17 +138,16 @@ export function useTimelinePlayhead({
const handleRulerMouseDown = useCallback(
({ event }: { event: React.MouseEvent }) => {
if (event.button !== 0) return;
-
if (playheadRef?.current?.contains(event.target as Node)) return;
event.preventDefault();
- setIsDraggingRuler(true);
- setHasDraggedRuler(false);
+ isDraggingRulerRef.current = true;
+ hasDraggedRulerRef.current = false;
- editor.playback.setScrubbing({ isScrubbing: true });
- handleScrub({ event, snappingEnabled: false });
- },
- [handleScrub, playheadRef, editor.playback],
+ editor.playback.setScrubbing({ isScrubbing: true });
+ handleScrub({ event, snappingEnabled: false });
+ },
+ [handleScrub, playheadRef, editor.playback],
);
const handlePlayheadMouseDownEvent = useCallback(
@@ -168,31 +173,32 @@ export function useTimelinePlayhead({
const handleMouseMove = ({ event }: { event: MouseEvent }) => {
handleScrub({ event });
- if (isDraggingRuler) {
- setHasDraggedRuler(true);
+ if (isDraggingRulerRef.current) {
+ hasDraggedRulerRef.current = true;
}
};
const handleMouseUp = ({ event }: { event: MouseEvent }) => {
editor.playback.setScrubbing({ isScrubbing: false });
- if (scrubTime !== null) {
- seek({ time: scrubTime });
+ const finalTime = scrubTimeRef.current;
+ if (finalTime !== null) {
+ seek({ time: finalTime });
editor.project.setTimelineViewState({
viewState: {
zoomLevel,
scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
- playheadTime: scrubTime,
+ playheadTime: finalTime,
},
});
}
- setScrubTime(null);
+ scrubTimeRef.current = null;
- if (isDraggingRuler) {
- setIsDraggingRuler(false);
- if (!hasDraggedRuler) {
+ if (isDraggingRulerRef.current) {
+ isDraggingRulerRef.current = false;
+ if (!hasDraggedRulerRef.current) {
handleScrub({ event, snappingEnabled: false });
}
- setHasDraggedRuler(false);
+ hasDraggedRulerRef.current = false;
}
};
@@ -206,55 +212,79 @@ export function useTimelinePlayhead({
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
- }, [
- isScrubbing,
- scrubTime,
- seek,
- handleScrub,
- isDraggingRuler,
- hasDraggedRuler,
- editor,
- tracksScrollRef,
- zoomLevel,
- ]);
+ }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
+
+ const updatePlayheadLeft = useCallback(
+ (time: number) => {
+ const playheadEl = playheadRef?.current;
+ if (!playheadEl) return;
+ const centerPosition = timelineTimeToSnappedPixels({
+ time,
+ zoomLevel: zoomLevelRef.current,
+ });
+ const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
+ const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0;
+ playheadEl.style.left = `${leftPosition - scrollLeft}px`;
+ },
+ [playheadRef, rulerScrollRef],
+ );
useEffect(() => {
- if (!isPlaying || isScrubbing) return;
+ const scrollEl = rulerScrollRef.current;
+ if (!scrollEl) return;
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) return;
+ const handleScroll = () => {
+ updatePlayheadLeft(editor.playback.getCurrentTime());
+ };
- const playheadPixels =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const viewportWidth = rulerViewport.clientWidth;
- const scrollMinimum = 0;
- const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
+ scrollEl.addEventListener("scroll", handleScroll, { passive: true });
+ return () => scrollEl.removeEventListener("scroll", handleScroll);
+ }, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
- const needsScroll =
- playheadPixels < rulerViewport.scrollLeft ||
- playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+ useEffect(() => {
+ const handlePlaybackUpdate = (e: Event) => {
+ const time = (e as CustomEvent<{ time: number }>).detail.time;
+ updatePlayheadLeft(time);
- if (needsScroll) {
- const desiredScroll = Math.max(
- scrollMinimum,
- Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
- );
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- }, [
- playheadPosition,
- zoomLevel,
- rulerScrollRef,
- tracksScrollRef,
- isScrubbing,
- isPlaying,
- ]);
+ if (!isPlayingRef.current || isScrubbingRef.current) return;
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ if (!rulerViewport || !tracksViewport) return;
+
+ const playheadPixels =
+ time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevelRef.current;
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMinimum = 0;
+ const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
+
+ const needsScroll =
+ playheadPixels < rulerViewport.scrollLeft ||
+ playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+
+ if (needsScroll) {
+ const desiredScroll = Math.max(
+ scrollMinimum,
+ Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
+ );
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ };
+
+ const initialTime = editor.playback.getCurrentTime();
+ handlePlaybackUpdate({
+ detail: { time: initialTime },
+ } as CustomEvent<{ time: number }>);
+
+ window.addEventListener("playback-update", handlePlaybackUpdate);
+ window.addEventListener("playback-seek", handlePlaybackUpdate);
+ return () => {
+ window.removeEventListener("playback-update", handlePlaybackUpdate);
+ window.removeEventListener("playback-seek", handlePlaybackUpdate);
+ };
+ }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
return {
- playheadPosition,
handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
handleRulerMouseDown: handleRulerMouseDownEvent,
- isDraggingRuler,
};
}
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
index 095c64dc..64a0fb58 100644
--- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
@@ -81,18 +81,22 @@ export function useTimelineZoom({
return;
}
- // pinch-zoom (ctrl/meta + wheel)
- if (isZoomGesture) {
- const zoomMultiplier = event.deltaY > 0 ? 1 / 1.1 : 1.1;
- setZoomLevel((prev) => {
- const nextZoom = Math.max(
- minZoom,
- Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomMultiplier),
- );
- return nextZoom;
- });
- return;
- }
+ // pinch-zoom (ctrl/meta + wheel)
+ if (isZoomGesture) {
+ const normalizedDelta =
+ event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
+ const cappedDelta =
+ Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+ setZoomLevel((prev) => {
+ const nextZoom = Math.max(
+ minZoom,
+ Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomFactor),
+ );
+ return nextZoom;
+ });
+ return;
+ }
},
[minZoom, setZoomLevel],
);
diff --git a/apps/web/src/hooks/use-container-size.ts b/apps/web/src/hooks/use-container-size.ts
index 8b46f20e..6f6f89be 100644
--- a/apps/web/src/hooks/use-container-size.ts
+++ b/apps/web/src/hooks/use-container-size.ts
@@ -1,4 +1,5 @@
-import { useEffect, useState } from "react";
+import { useCallback, useState } from "react";
+import { useResizeObserver } from "./use-resize-observer";
export function useContainerSize({
containerRef,
@@ -7,23 +8,12 @@ export function useContainerSize({
}) {
const [size, setSize] = useState({ width: 0, height: 0 });
- useEffect(() => {
- const container = containerRef.current;
- if (!container) return;
+ const onResize = useCallback((entry: ResizeObserverEntry) => {
+ const { width, height } = entry.contentRect;
+ setSize({ width, height });
+ }, []);
- const observer = new ResizeObserver((entries) => {
- for (const entry of entries) {
- const { width, height } = entry.contentRect;
- setSize({ width, height });
- }
- });
-
- observer.observe(container);
-
- return () => {
- observer.disconnect();
- };
- }, [containerRef]);
+ useResizeObserver({ ref: containerRef, onResize });
return size;
}
diff --git a/apps/web/src/hooks/use-editor.ts b/apps/web/src/hooks/use-editor.ts
index ff3e2db1..e211a85b 100644
--- a/apps/web/src/hooks/use-editor.ts
+++ b/apps/web/src/hooks/use-editor.ts
@@ -1,39 +1,58 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { EditorCore } from "@/core";
-export function useEditor(): EditorCore {
+function isShallowEqual(a: unknown, b: unknown): boolean {
+ if (Object.is(a, b)) return true;
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
+ if (a.length !== b.length) return false;
+ return a.every((item, i) => Object.is(item, b[i]));
+}
+
+const subscribeNone = () => () => {};
+
+export function useEditor(): EditorCore;
+export function useEditor<T>(selector: (editor: EditorCore) => T): T;
+export function useEditor<T>(
+ selector?: (editor: EditorCore) => T,
+): EditorCore | T {
const editor = useMemo(() => EditorCore.getInstance(), []);
- const versionRef = useRef(0);
+ const selectorRef = useRef(selector);
+ selectorRef.current = selector;
- const subscribe = useCallback(
- (onStoreChange: () => void) => {
- const handleStoreChange = () => {
- versionRef.current += 1;
- onStoreChange();
- };
+ const snapshotCacheRef = useRef<unknown>(undefined);
+ const subscribeAll = useCallback(
+ (onChange: () => void) => {
const unsubscribers = [
- editor.playback.subscribe(handleStoreChange),
- editor.timeline.subscribe(handleStoreChange),
- editor.scenes.subscribe(handleStoreChange),
- editor.project.subscribe(handleStoreChange),
- editor.media.subscribe(handleStoreChange),
- editor.renderer.subscribe(handleStoreChange),
- editor.selection.subscribe(handleStoreChange),
+ editor.playback.subscribe(onChange),
+ editor.timeline.subscribe(onChange),
+ editor.scenes.subscribe(onChange),
+ editor.project.subscribe(onChange),
+ editor.media.subscribe(onChange),
+ editor.renderer.subscribe(onChange),
+ editor.selection.subscribe(onChange),
];
-
return () => {
- for (const unsubscribe of unsubscribers) {
+ unsubscribers.forEach((unsubscribe) => {
unsubscribe();
- }
+ });
};
},
[editor],
);
- const getSnapshot = useCallback(() => versionRef.current, []);
-
- useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ const getSnapshot = useCallback(() => {
+ const next = selectorRef.current ? selectorRef.current(editor) : editor;
+ if (isShallowEqual(snapshotCacheRef.current, next)) {
+ return snapshotCacheRef.current;
+ }
+ snapshotCacheRef.current = next;
+ return next;
+ }, [editor]);
- return editor;
+ return useSyncExternalStore(
+ selector ? subscribeAll : subscribeNone,
+ getSnapshot,
+ getSnapshot,
+ ) as EditorCore | T;
}
diff --git a/apps/web/src/hooks/use-effect-preview.ts b/apps/web/src/hooks/use-effect-preview.ts
deleted file mode 100644
index a84781e0..00000000
--- a/apps/web/src/hooks/use-effect-preview.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { useEffect, useRef } from "react";
-import { effectPreviewService } from "@/services/renderer/effect-preview";
-import type { EffectParamValues } from "@/types/effects";
-
-export function useEffectPreview({
- effectType,
- params,
- canvasRef,
- isActive,
-}: {
- effectType: string;
- params: EffectParamValues;
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
- isActive: boolean;
-}): void {
- const requestRef = useRef<number>(0);
-
- useEffect(() => {
- if (!isActive) {
- if (requestRef.current) {
- cancelAnimationFrame(requestRef.current);
- requestRef.current = 0;
- }
- return;
- }
-
- const loop = (): void => {
- const canvas = canvasRef.current;
- if (canvas) {
- effectPreviewService.renderPreview({
- effectType,
- params,
- targetCanvas: canvas,
- });
- }
- requestRef.current = requestAnimationFrame(loop);
- };
-
- requestRef.current = requestAnimationFrame(loop);
-
- return () => {
- if (requestRef.current) {
- cancelAnimationFrame(requestRef.current);
- }
- };
- }, [effectType, params, canvasRef, isActive]);
-}
diff --git a/apps/web/src/hooks/use-element-preview.ts b/apps/web/src/hooks/use-element-preview.ts
new file mode 100644
index 00000000..8aa16501
--- /dev/null
+++ b/apps/web/src/hooks/use-element-preview.ts
@@ -0,0 +1,38 @@
+import { useEditor } from "@/hooks/use-editor";
+import type { TimelineElement } from "@/lib/timeline";
+
+/**
+ * Subscribes to render tracks and returns the live (preview-aware) version of
+ * an element alongside helpers for previewing and committing updates.
+ *
+ * Use this wherever property fields need to reflect in-progress preview state
+ * (e.g. a slider being dragged) rather than the last committed value.
+ */
+export function useElementPreview<T extends TimelineElement>({
+ trackId,
+ elementId,
+ fallback,
+}: {
+ trackId: string;
+ elementId: string;
+ fallback: T;
+}) {
+ const editor = useEditor();
+ useEditor((e) => e.timeline.getRenderTracks());
+
+ const renderElement =
+ (editor.timeline
+ .getRenderTracks()
+ .find((t) => t.id === trackId)
+ ?.elements.find((el) => el.id === elementId) as T | undefined) ??
+ fallback;
+
+ const previewUpdates = (updates: Partial<TimelineElement>) =>
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates }],
+ });
+
+ const commit = () => editor.timeline.commitPreview();
+
+ return { renderElement, previewUpdates, commit };
+}
diff --git a/apps/web/src/hooks/use-file-upload.ts b/apps/web/src/hooks/use-file-upload.ts
index 19badd98..d174e002 100644
--- a/apps/web/src/hooks/use-file-upload.ts
+++ b/apps/web/src/hooks/use-file-upload.ts
@@ -4,7 +4,7 @@ import { hasDragData } from "@/lib/drag-data";
interface UseFileUploadOptions {
accept?: string;
multiple?: boolean;
- onFilesSelected?: (files: FileList) => void;
+ onFilesSelected?: (files: File[]) => void;
}
function containsFiles(dataTransfer: DataTransfer): boolean {
@@ -29,8 +29,8 @@ export function useFileUpload({
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
- const files = event.target.files;
- if (files && files.length > 0 && onFilesSelected) {
+ const files = Array.from(event.target.files ?? []);
+ if (files.length > 0 && onFilesSelected) {
onFilesSelected(files);
}
@@ -71,15 +71,13 @@ export function useFileUpload({
dragCounterRef.current = 0;
if (onFilesSelected && containsFiles(e.dataTransfer)) {
- const files = e.dataTransfer.files;
+ const files = Array.from(e.dataTransfer.files);
const shouldUseMultiple = multiple ?? false;
if (shouldUseMultiple) {
onFilesSelected(files);
} else if (files.length > 0) {
- const dataTransfer = new DataTransfer();
- dataTransfer.items.add(files[0]);
- onFilesSelected(dataTransfer.files);
+ onFilesSelected([files[0]]);
}
}
}
diff --git a/apps/web/src/hooks/use-font-atlas.ts b/apps/web/src/hooks/use-font-atlas.ts
new file mode 100644
index 00000000..7d6b87e9
--- /dev/null
+++ b/apps/web/src/hooks/use-font-atlas.ts
@@ -0,0 +1,53 @@
+import { useState, useMemo, useCallback, useEffect } from "react";
+import {
+ getCachedFontAtlas,
+ loadFontAtlas,
+ clearFontAtlasCache,
+} from "@/lib/fonts/google-fonts";
+import type { FontAtlas } from "@/lib/fonts/types";
+import { SYSTEM_FONTS } from "@/constants/font-constants";
+
+type Status = "idle" | "loading" | "error";
+
+export function useFontAtlas({ open }: { open: boolean }) {
+ const [atlas, setAtlas] = useState<FontAtlas | null>(() =>
+ getCachedFontAtlas(),
+ );
+ const [status, setStatus] = useState<Status>(() =>
+ getCachedFontAtlas() ? "idle" : "loading",
+ );
+
+ useEffect(() => {
+ if (!open || atlas) return;
+
+ setStatus("loading");
+ loadFontAtlas().then((data) => {
+ if (data) {
+ setAtlas(data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ }
+ });
+ }, [open, atlas]);
+
+ const retry = useCallback(() => {
+ clearFontAtlasCache();
+ setStatus("loading");
+ loadFontAtlas().then((data) => {
+ if (data) {
+ setAtlas(data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ }
+ });
+ }, []);
+
+ const fontNames = useMemo(() => {
+ if (!atlas) return [];
+ return [...Object.keys(atlas.fonts), ...SYSTEM_FONTS].sort();
+ }, [atlas]);
+
+ return { atlas, status, fontNames, retry };
+}
diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts
index e59d1eaf..69a4e847 100644
--- a/apps/web/src/hooks/use-keybindings.ts
+++ b/apps/web/src/hooks/use-keybindings.ts
@@ -1,6 +1,8 @@
import { useEffect } from "react";
import { invokeAction } from "@/lib/actions";
import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { isTypableDOMElement } from "@/utils/browser";
/**
* a composable that hooks to the caller component's
@@ -8,31 +10,141 @@ import { useKeybindingsStore } from "@/stores/keybindings-store";
* the appropriate actions based on keybindings
*/
export function useKeybindingsListener() {
- const { keybindings, getKeybindingString, keybindingsEnabled, isRecording } =
- useKeybindingsStore();
+ const {
+ keybindings,
+ getKeybindingString,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ } = useKeybindingsStore();
+ const clipboard = useTimelineStore((state) => state.clipboard);
useEffect(() => {
const eventOptions: AddEventListenerOptions = { capture: true };
+ // #region agent log
+ fetch("http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H1",
+ location: "use-keybindings.ts:effect",
+ message: "Keybindings listener mounted",
+ data: {
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ keybindingCount: Object.keys(keybindings).length,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
const handleKeyDown = (ev: KeyboardEvent) => {
- // do not check keybinds if the mode is disabled
- if (!keybindingsEnabled) return;
- // ignore key events if user is changing keybindings
- if (isRecording) return;
+ const normalizedKey = (ev.key ?? "").toLowerCase();
+ const shouldLogKey =
+ ev.code === "Space" ||
+ ev.code.startsWith("Key") ||
+ ["escape", "delete", "backspace", "enter"].includes(normalizedKey);
- const binding = getKeybindingString(ev);
- if (!binding) return;
-
- const boundAction = keybindings[binding];
- if (!boundAction) return;
+ if (overlayDepth > 0 || isLoadingProject || isRecording) {
+ if (shouldLogKey) {
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H2",
+ location: "use-keybindings.ts:blocked",
+ message: "Shortcut blocked by runtime gate",
+ data: {
+ key: ev.key,
+ code: ev.code,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ targetTag:
+ ev.target instanceof HTMLElement ? ev.target.tagName : null,
+ },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ }
+ return;
+ }
+ const binding = getKeybindingString(ev);
const activeElement = document.activeElement;
const isTextInput =
- activeElement &&
- (activeElement.tagName === "INPUT" ||
- activeElement.tagName === "TEXTAREA" ||
- (activeElement as HTMLElement).isContentEditable);
+ activeElement instanceof HTMLElement &&
+ isTypableDOMElement({ element: activeElement });
+ const boundAction = binding ? keybindings[binding] : undefined;
+
+ if (shouldLogKey || binding || boundAction) {
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: !binding ? "H3" : isTextInput ? "H5" : "H4",
+ location: "use-keybindings.ts:keydown",
+ message: "Shortcut keydown observed",
+ data: {
+ key: ev.key,
+ code: ev.code,
+ binding,
+ boundAction: boundAction ?? null,
+ isTextInput,
+ keybindingCount: Object.keys(keybindings).length,
+ activeTag:
+ activeElement instanceof HTMLElement
+ ? activeElement.tagName
+ : null,
+ targetTag:
+ ev.target instanceof HTMLElement ? ev.target.tagName : null,
+ },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ }
+
+ if (normalizedKey === "escape" && isTextInput) {
+ activeElement.blur();
+ return;
+ }
+
+ if (!binding) return;
+ if (!boundAction) return;
if (isTextInput) return;
+ if (boundAction === "paste-copied") {
+ if (!clipboard?.items.length) return;
+ ev.preventDefault();
+ invokeAction("paste-copied", undefined, "keypress");
+ return;
+ }
ev.preventDefault();
@@ -59,17 +171,12 @@ export function useKeybindingsListener() {
return () => {
document.removeEventListener("keydown", handleKeyDown, eventOptions);
};
- }, [keybindings, getKeybindingString, keybindingsEnabled, isRecording]);
-}
-
-/**
- * this composable allows for the UI component to be disabled if the component in question is mounted
- */
-export function useKeybindingDisabler() {
- const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
-
- return {
- disableKeybindings,
- enableKeybindings,
- };
+ }, [
+ keybindings,
+ getKeybindingString,
+ overlayDepth,
+ isLoadingProject,
+ isRecording,
+ clipboard,
+ ]);
}
diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts
new file mode 100644
index 00000000..c3e83304
--- /dev/null
+++ b/apps/web/src/hooks/use-mask-handles.ts
@@ -0,0 +1,258 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { masksRegistry } from "@/lib/masks";
+import {
+ getMaskHandlePositions,
+ getLineMaskLinePoints,
+} from "@/lib/masks/handle-positions";
+import { snapMaskInteraction } from "@/lib/masks/snap";
+import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
+import {
+ SNAP_THRESHOLD_SCREEN_PIXELS,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
+import type { ParamValues } from "@/lib/params";
+import type {
+ BaseMaskParams,
+ MaskHandlePosition,
+ MaskLinePoints,
+} from "@/lib/masks/types";
+import type { MaskableElement } from "@/lib/timeline";
+import { registerCanceller } from "@/lib/cancel-interaction";
+
+interface DragState {
+ trackId: string;
+ elementId: string;
+ handleId: string;
+ startCanvasX: number;
+ startCanvasY: number;
+ startParams: BaseMaskParams & ParamValues;
+}
+
+export function useMaskHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
+ const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
+ const dragStateRef = useRef<DragState | null>(null);
+ const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ null,
+ );
+
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const selectedWithMask =
+ selectedElements.length === 1
+ ? (() => {
+ const sel = selectedElements[0];
+ const entry = elementsWithBounds.find(
+ (item) =>
+ item.trackId === sel.trackId && item.elementId === sel.elementId,
+ );
+ if (!entry) return null;
+ const element = entry.element as MaskableElement;
+ if (!element.masks?.length) return null;
+ return { ...entry, element, mask: element.masks[0] };
+ })()
+ : null;
+
+ const handlePositions: MaskHandlePosition[] = selectedWithMask
+ ? (() => {
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+ const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
+ const displayScale = (scaleX + scaleY) / 2;
+ return getMaskHandlePositions({
+ overlayShape: def.overlayShape,
+ features: def.features,
+ params: selectedWithMask.mask.params,
+ bounds: selectedWithMask.bounds,
+ displayScale,
+ });
+ })()
+ : [];
+
+ const linePoints: MaskLinePoints | null =
+ selectedWithMask?.mask.type === "split"
+ ? getLineMaskLinePoints({
+ centerX: selectedWithMask.mask.params.centerX,
+ centerY: selectedWithMask.mask.params.centerY,
+ rotation: selectedWithMask.mask.params.rotation,
+ bounds: selectedWithMask.bounds,
+ })
+ : null;
+
+ const clearMaskHandleState = useCallback(() => {
+ dragStateRef.current = null;
+ setActiveHandleId(null);
+ onSnapLinesChange?.([]);
+ }, [onSnapLinesChange]);
+
+ const releaseCapturedPointer = useCallback(() => {
+ const capture = captureRef.current;
+ if (!capture) return;
+
+ if (capture.element.hasPointerCapture(capture.pointerId)) {
+ capture.element.releasePointerCapture(capture.pointerId);
+ }
+
+ captureRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!activeHandleId) return;
+
+ return registerCanceller({
+ fn: () => {
+ editor.timeline.discardPreview();
+ clearMaskHandleState();
+ releaseCapturedPointer();
+ },
+ });
+ }, [
+ activeHandleId,
+ clearMaskHandleState,
+ editor.timeline,
+ releaseCapturedPointer,
+ ]);
+
+ const handlePointerDown = useCallback(
+ ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
+ if (!selectedWithMask) return;
+ event.stopPropagation();
+
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) return;
+
+ dragStateRef.current = {
+ trackId: selectedWithMask.trackId,
+ elementId: selectedWithMask.elementId,
+ handleId,
+ startCanvasX: pos.x,
+ startCanvasY: pos.y,
+ startParams: { ...selectedWithMask.mask.params },
+ };
+ setActiveHandleId(handleId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithMask, viewport],
+ );
+
+ const handlePointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const drag = dragStateRef.current;
+ if (!drag || !selectedWithMask) return;
+
+ const pos = viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!pos) return;
+
+ const deltaX = pos.x - drag.startCanvasX;
+ const deltaY = pos.y - drag.startCanvasY;
+ const def = masksRegistry.get(selectedWithMask.mask.type);
+
+ const rawParams = def.computeParamUpdate({
+ handleId: drag.handleId,
+ startParams: drag.startParams,
+ deltaX,
+ deltaY,
+ startCanvasX: drag.startCanvasX,
+ startCanvasY: drag.startCanvasY,
+ bounds: selectedWithMask.bounds,
+ canvasSize,
+ });
+ const proposedParams = { ...drag.startParams, ...rawParams };
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { params: nextParams, activeLines } = isShiftHeldRef.current
+ ? { params: proposedParams, activeLines: [] as SnapLine[] }
+ : snapMaskInteraction({
+ handleId: drag.handleId,
+ startParams: drag.startParams,
+ proposedParams,
+ bounds: selectedWithMask.bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ onSnapLinesChange?.(activeLines);
+
+ const updatedMask = {
+ ...selectedWithMask.mask,
+ params: nextParams,
+ };
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: drag.trackId,
+ elementId: drag.elementId,
+ updates: {
+ masks: [
+ updatedMask,
+ ...(selectedWithMask.element.masks?.slice(1) ?? []),
+ ],
+ } as Partial<MaskableElement>,
+ },
+ ],
+ });
+ },
+ [
+ selectedWithMask,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
+ );
+
+ const handlePointerUp = useCallback(() => {
+ if (dragStateRef.current) {
+ editor.timeline.commitPreview();
+ clearMaskHandleState();
+ }
+ releaseCapturedPointer();
+ },
+ [clearMaskHandleState, editor, releaseCapturedPointer],
+ );
+
+ return {
+ selectedWithMask,
+ handlePositions,
+ linePoints,
+ activeHandleId,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ };
+}
diff --git a/apps/web/src/hooks/use-menu-preview.ts b/apps/web/src/hooks/use-menu-preview.ts
new file mode 100644
index 00000000..a17ee278
--- /dev/null
+++ b/apps/web/src/hooks/use-menu-preview.ts
@@ -0,0 +1,26 @@
+import { useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+
+export function useMenuPreview() {
+ const editor = useEditor();
+ const didCommitRef = useRef(false);
+
+ const discard = () => {
+ if (!didCommitRef.current && editor.timeline.isPreviewActive()) {
+ editor.timeline.discardPreview();
+ }
+ };
+
+ const markCommitted = () => {
+ didCommitRef.current = true;
+ };
+
+ const onOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ discard();
+ didCommitRef.current = false;
+ }
+ };
+
+ return { onPointerLeave: discard, onOpenChange, markCommitted };
+}
diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts
index 4533ab28..8f362a12 100644
--- a/apps/web/src/hooks/use-paste-media.ts
+++ b/apps/web/src/hooks/use-paste-media.ts
@@ -1,14 +1,15 @@
import { useEffect } from "react";
-import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import { processMediaAssets } from "@/lib/media/processing";
+import { showMediaUploadToast } from "@/lib/media/upload-toast";
+import { invokeAction } from "@/lib/actions";
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { AddMediaAssetCommand } from "@/lib/commands/media";
import { InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { isTypableDOMElement } from "@/utils/browser";
-import type { MediaType } from "@/types/assets";
+import type { MediaType } from "@/lib/media/types";
const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
@@ -40,6 +41,7 @@ export function usePasteMedia() {
useEffect(() => {
const handlePaste = async (event: ClipboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
+
if (activeElement && isTypableDOMElement({ element: activeElement })) {
return;
}
@@ -47,7 +49,11 @@ export function usePasteMedia() {
const files = extractMediaFilesFromClipboard({
clipboardData: event.clipboardData,
});
- if (files.length === 0) return;
+ if (files.length === 0) {
+ event.preventDefault();
+ invokeAction("paste-copied");
+ return;
+ }
event.preventDefault();
@@ -55,41 +61,50 @@ export function usePasteMedia() {
if (!activeProject) return;
try {
- const processedAssets = await processMediaAssets({ files });
- const startTime = editor.playback.getCurrentTime();
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+ const startTime = editor.playback.getCurrentTime();
+
+ for (const asset of processedAssets) {
+ const addMediaCmd = new AddMediaAssetCommand(
+ activeProject.metadata.id,
+ asset,
+ );
+ const assetId = addMediaCmd.getAssetId();
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const trackType = asset.type === "audio" ? "audio" : "video";
- for (const asset of processedAssets) {
- const addMediaCmd = new AddMediaAssetCommand(
- activeProject.metadata.id,
- asset,
- );
- const assetId = addMediaCmd.getAssetId();
- const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const trackType = asset.type === "audio" ? "audio" : "video";
+ const element = buildElementFromMedia({
+ mediaId: assetId,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ buffer:
+ asset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
- const element = buildElementFromMedia({
- mediaId: assetId,
- mediaType: asset.type,
- name: asset.name,
- duration,
- startTime,
- buffer:
- asset.type === "audio"
- ? new AudioBuffer({ length: 1, sampleRate: 44100 })
- : undefined,
- });
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "auto", trackType },
+ });
+ const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
+ editor.command.execute({ command: batchCmd });
+ }
- const insertCmd = new InsertElementCommand({
- element,
- placement: { mode: "auto", trackType },
- });
- const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
- editor.command.execute({ command: batchCmd });
- }
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
} catch (error) {
console.error("Failed to paste media:", error);
- toast.error("Failed to paste media");
}
};
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
index d29f2a4b..90fa0450 100644
--- a/apps/web/src/hooks/use-preview-interaction.ts
+++ b/apps/web/src/hooks/use-preview-interaction.ts
@@ -1,28 +1,50 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
-import type { TextElement, Transform } from "@/types/timeline";
-import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
-import { hitTest } from "@/lib/preview/hit-test";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import type { Transform } from "@/lib/rendering";
+import type { ElementRef, TextElement } from "@/lib/timeline";
import {
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
+ getVisibleElementsWithBounds,
+ type ElementWithBounds,
+} from "@/lib/preview/element-bounds";
+import {
+ getHitElements,
+ hitTest,
+ resolvePreferredHit,
+} from "@/lib/preview/hit-test";
import { isVisualElement } from "@/lib/timeline/element-utils";
import {
SNAP_THRESHOLD_SCREEN_PIXELS,
snapPosition,
type SnapLine,
} from "@/lib/preview/preview-snap";
+import { registerCanceller } from "@/lib/cancel-interaction";
+
+export type OnSnapLinesChange = (lines: SnapLine[]) => void;
const MIN_DRAG_DISTANCE = 0.5;
-interface DragState {
+interface CapturedPointerState {
+ pointerId: number;
+ captureTarget: HTMLElement;
+}
+
+interface PendingGestureState extends CapturedPointerState {
+ startX: number;
+ startY: number;
+ topmostHit: ElementWithBounds | null;
+ selectedHit: ElementWithBounds | null;
+ selectedElements: ElementRef[];
+}
+
+interface DragState extends CapturedPointerState {
startX: number;
startY: number;
bounds: {
width: number;
height: number;
+ rotation: number;
};
elements: Array<{
trackId: string;
@@ -31,15 +53,56 @@ interface DragState {
}>;
}
+function isSameElementRef({
+ left,
+ right,
+}: {
+ left: ElementRef;
+ right: ElementRef;
+}): boolean {
+ return left.trackId === right.trackId && left.elementId === right.elementId;
+}
+
+function buildDragSelection({
+ selectedElements,
+ dragTarget,
+}: {
+ selectedElements: ElementRef[];
+ dragTarget: ElementWithBounds;
+}): ElementRef[] {
+ const dragTargetRef = {
+ trackId: dragTarget.trackId,
+ elementId: dragTarget.elementId,
+ };
+
+ if (
+ !selectedElements.some((selectedElement) =>
+ isSameElementRef({ left: selectedElement, right: dragTargetRef }),
+ )
+ ) {
+ return [dragTargetRef];
+ }
+
+ return [
+ dragTargetRef,
+ ...selectedElements.filter(
+ (selectedElement) =>
+ !isSameElementRef({ left: selectedElement, right: dragTargetRef }),
+ ),
+ ];
+}
+
export function usePreviewInteraction({
- canvasRef,
+ onSnapLinesChange,
+ isMaskMode = false,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ onSnapLinesChange?: OnSnapLinesChange;
+ isMaskMode?: boolean;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
const [isDragging, setIsDragging] = useState(false);
- const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
const [editingText, setEditingText] = useState<{
trackId: string;
elementId: string;
@@ -47,13 +110,30 @@ export function usePreviewInteraction({
originalOpacity: number;
} | null>(null);
const dragStateRef = useRef<DragState | null>(null);
+ const pendingGestureRef = useRef<PendingGestureState | null>(null);
const wasPlayingRef = useRef(editor.playback.getIsPlaying());
const editingTextRef = useRef(editingText);
editingTextRef.current = editingText;
+ const releaseCapturedPointer = useCallback(
+ (pointerState: CapturedPointerState | null) => {
+ if (!pointerState) return;
+
+ if (
+ !pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)
+ ) {
+ return;
+ }
+
+ pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
+ },
+ [],
+ );
+
const commitTextEdit = useCallback(() => {
const current = editingTextRef.current;
if (!current) return;
+ editingTextRef.current = null;
editor.timeline.previewElements({
updates: [
{
@@ -67,11 +147,6 @@ export function usePreviewInteraction({
setEditingText(null);
}, [editor.timeline]);
- const cancelTextEdit = useCallback(() => {
- editor.timeline.discardPreview();
- setEditingText(null);
- }, [editor.timeline]);
-
useEffect(() => {
const unsubscribe = editor.playback.subscribe(() => {
const isPlaying = editor.playback.getIsPlaying();
@@ -83,20 +158,38 @@ export function usePreviewInteraction({
return unsubscribe;
}, [editor.playback, commitTextEdit]);
+ useEffect(() => {
+ if (!isDragging) return;
+
+ return registerCanceller({
+ fn: () => {
+ const dragState = dragStateRef.current;
+ if (!dragState) return;
+
+ editor.timeline.discardPreview();
+ dragStateRef.current = null;
+ pendingGestureRef.current = null;
+ setIsDragging(false);
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(dragState);
+ },
+ });
+ }, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]);
+
const handleDoubleClick = useCallback(
({ clientX, clientY }: React.MouseEvent) => {
- if (!canvasRef.current || editingText) return;
+ if (editingText || isMaskMode) return;
const tracks = editor.timeline.getTracks();
const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
- const startPos = screenToCanvas({
+ const startPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!startPos) return;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -130,7 +223,7 @@ export function usePreviewInteraction({
originalOpacity: textElement.opacity,
});
},
- [canvasRef, editor, editingText],
+ [editor, editingText, isMaskMode, viewport],
);
const handlePointerDown = useCallback(
@@ -141,8 +234,8 @@ export function usePreviewInteraction({
pointerId,
button,
}: React.PointerEvent) => {
- if (!canvasRef.current) return;
if (editingText) return;
+ if (isMaskMode) return;
if (button !== 0) return;
const tracks = editor.timeline.getTracks();
@@ -150,11 +243,11 @@ export function usePreviewInteraction({
const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize;
- const startPos = screenToCanvas({
+ const startPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!startPos) return;
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -163,89 +256,134 @@ export function usePreviewInteraction({
mediaAssets,
});
- const hit = hitTest({
+ const hits = getHitElements({
canvasX: startPos.x,
canvasY: startPos.y,
elementsWithBounds,
});
+ const selectedElements = editor.selection.getSelectedElements();
+ const topmostHit = hits[0] ?? null;
- if (!hit) {
- editor.selection.clearSelection();
- return;
- }
-
- editor.selection.setSelectedElements({
- elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
- });
-
- const elementsWithTracks = editor.timeline.getElementsWithTracks({
- elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
- });
-
- const draggableElements = elementsWithTracks.filter(({ element }) =>
- isVisualElement(element),
- );
-
- if (draggableElements.length === 0) return;
-
- dragStateRef.current = {
+ pendingGestureRef.current = {
startX: startPos.x,
startY: startPos.y,
- bounds: {
- width: hit.bounds.width,
- height: hit.bounds.height,
- },
- elements: draggableElements.map(({ track, element }) => ({
- trackId: track.id,
- elementId: element.id,
- initialTransform: (element as { transform: Transform }).transform,
- })),
+ pointerId,
+ captureTarget: currentTarget as HTMLElement,
+ topmostHit,
+ selectedHit: resolvePreferredHit({
+ hits,
+ preferredElements: selectedElements,
+ }),
+ selectedElements,
};
-
- setIsDragging(true);
currentTarget.setPointerCapture(pointerId);
},
- [editor, canvasRef, editingText],
+ [editor, editingText, isMaskMode, viewport],
);
const handlePointerMove = useCallback(
({ clientX, clientY }: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
-
const canvasSize = editor.project.getActive().settings.canvasSize;
- const currentPos = screenToCanvas({
+ const currentPos = viewport.screenToCanvas({
clientX,
clientY,
- canvas: canvasRef.current,
});
+ if (!currentPos) return;
- const deltaX = currentPos.x - dragStateRef.current.startX;
- const deltaY = currentPos.y - dragStateRef.current.startY;
- const hasMovement =
- Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
- Math.abs(deltaY) > MIN_DRAG_DISTANCE;
- if (!hasMovement) {
- setSnapLines([]);
- return;
+ let dragState = dragStateRef.current;
+
+ if (!dragState) {
+ const pendingGesture = pendingGestureRef.current;
+ if (!pendingGesture) return;
+
+ const deltaX = currentPos.x - pendingGesture.startX;
+ const deltaY = currentPos.y - pendingGesture.startY;
+ const hasMovement =
+ Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
+ Math.abs(deltaY) > MIN_DRAG_DISTANCE;
+
+ if (!hasMovement) {
+ onSnapLinesChange?.([]);
+ return;
+ }
+
+ const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
+ if (!dragTarget) {
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
+ return;
+ }
+
+ const dragSelection = buildDragSelection({
+ selectedElements: pendingGesture.selectedElements,
+ dragTarget,
+ });
+ const elementsWithTracks = editor.timeline.getElementsWithTracks({
+ elements: dragSelection,
+ });
+ const draggableElements = elementsWithTracks.filter(({ element }) =>
+ isVisualElement(element),
+ );
+
+ if (draggableElements.length === 0) {
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
+ return;
+ }
+
+ if (pendingGesture.selectedHit === null) {
+ editor.selection.setSelectedElements({
+ elements: [
+ {
+ trackId: dragTarget.trackId,
+ elementId: dragTarget.elementId,
+ },
+ ],
+ });
+ }
+
+ dragState = {
+ startX: pendingGesture.startX,
+ startY: pendingGesture.startY,
+ pointerId: pendingGesture.pointerId,
+ captureTarget: pendingGesture.captureTarget,
+ bounds: {
+ width: dragTarget.bounds.width,
+ height: dragTarget.bounds.height,
+ rotation: dragTarget.bounds.rotation,
+ },
+ elements: draggableElements.map(({ track, element }) => ({
+ trackId: track.id,
+ elementId: element.id,
+ initialTransform: (element as { transform: Transform }).transform,
+ })),
+ };
+ dragStateRef.current = dragState;
+ pendingGestureRef.current = null;
+ setIsDragging(true);
}
- const firstElement = dragStateRef.current.elements[0];
+ const deltaX = currentPos.x - dragState.startX;
+ const deltaY = currentPos.y - dragState.startY;
+ const firstElement = dragState.elements[0];
const proposedPosition = {
x: firstElement.initialTransform.position.x + deltaX,
y: firstElement.initialTransform.position.y + deltaY,
};
const shouldSnap = !isShiftHeldRef.current;
- const snapThreshold = screenPixelsToLogicalThreshold({
- canvas: canvasRef.current,
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
const { snappedPosition, activeLines } = shouldSnap
? snapPosition({
proposedPosition,
canvasSize,
- elementSize: dragStateRef.current.bounds,
+ elementSize: dragState.bounds,
+ rotation: dragState.bounds.rotation,
snapThreshold,
})
: {
@@ -253,14 +391,14 @@ export function usePreviewInteraction({
activeLines: [] as SnapLine[],
};
- setSnapLines(activeLines);
+ onSnapLinesChange?.(activeLines);
const deltaSnappedX =
snappedPosition.x - firstElement.initialTransform.position.x;
const deltaSnappedY =
snappedPosition.y - firstElement.initialTransform.position.y;
- const updates = dragStateRef.current.elements.map(
+ const updates = dragState.elements.map(
({ trackId, elementId, initialTransform }) => ({
trackId,
elementId,
@@ -278,38 +416,51 @@ export function usePreviewInteraction({
editor.timeline.previewElements({ updates });
},
- [isDragging, canvasRef, editor, isShiftHeldRef],
+ [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
);
const handlePointerUp = useCallback(
- ({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
+ ({ type }: React.PointerEvent) => {
+ const dragState = dragStateRef.current;
+ if (dragState) {
+ if (type === "pointercancel") {
+ editor.timeline.discardPreview();
+ } else {
+ editor.timeline.commitPreview();
+ }
- const currentPos = screenToCanvas({
- clientX,
- clientY,
- canvas: canvasRef.current,
- });
-
- const deltaX = currentPos.x - dragStateRef.current.startX;
- const deltaY = currentPos.y - dragStateRef.current.startY;
+ dragStateRef.current = null;
+ pendingGestureRef.current = null;
+ setIsDragging(false);
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(dragState);
+ return;
+ }
- const hasMovement =
- Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
- Math.abs(deltaY) > MIN_DRAG_DISTANCE;
+ const pendingGesture = pendingGestureRef.current;
+ if (!pendingGesture) return;
- if (!hasMovement) {
- editor.timeline.discardPreview();
- } else {
- editor.timeline.commitPreview();
+ if (type !== "pointercancel") {
+ const clickTarget = pendingGesture.topmostHit;
+ if (!clickTarget) {
+ editor.selection.clearSelection();
+ } else {
+ editor.selection.setSelectedElements({
+ elements: [
+ {
+ trackId: clickTarget.trackId,
+ elementId: clickTarget.elementId,
+ },
+ ],
+ });
+ }
}
- dragStateRef.current = null;
- setIsDragging(false);
- setSnapLines([]);
- currentTarget.releasePointerCapture(pointerId);
+ pendingGestureRef.current = null;
+ onSnapLinesChange?.([]);
+ releaseCapturedPointer(pendingGesture);
},
- [isDragging, canvasRef, editor],
+ [editor, onSnapLinesChange, releaseCapturedPointer],
);
return {
@@ -317,9 +468,7 @@ export function usePreviewInteraction({
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onDoubleClick: handleDoubleClick,
- snapLines,
editingText,
commitTextEdit,
- cancelTextEdit,
};
}
diff --git a/apps/web/src/hooks/use-resize-observer.ts b/apps/web/src/hooks/use-resize-observer.ts
new file mode 100644
index 00000000..a4b3a603
--- /dev/null
+++ b/apps/web/src/hooks/use-resize-observer.ts
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+
+export function useResizeObserver({
+ ref,
+ onResize,
+}: {
+ ref: React.RefObject<HTMLElement | null>;
+ onResize: (entry: ResizeObserverEntry) => void;
+}) {
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) return;
+
+ const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ onResize(entry);
+ }
+ });
+
+ observer.observe(element);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [ref, onResize]);
+}
diff --git a/apps/web/src/hooks/use-reveal-item.ts b/apps/web/src/hooks/use-reveal-item.ts
deleted file mode 100644
index 5004f60c..00000000
--- a/apps/web/src/hooks/use-reveal-item.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { useEffect, useState, useRef } from "react";
-
-export function useRevealItem(
- highlightId: string | null,
- onClearHighlight: () => void,
- highlightDuration = 1000,
-) {
- const [highlightedId, setHighlightedId] = useState<string | null>(null);
- const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
-
- const registerElement = (id: string, element: HTMLElement | null) => {
- if (element) {
- elementRefs.current.set(id, element);
- } else {
- elementRefs.current.delete(id);
- }
- };
-
- useEffect(() => {
- if (!highlightId) return;
-
- setHighlightedId(highlightId);
-
- const target = elementRefs.current.get(highlightId);
- target?.scrollIntoView({ block: "center" });
-
- const timeout = setTimeout(() => {
- setHighlightedId(null);
- onClearHighlight();
- }, highlightDuration);
-
- return () => clearTimeout(timeout);
- }, [highlightId, onClearHighlight, highlightDuration]);
-
- return { highlightedId, registerElement };
-}
diff --git a/apps/web/src/hooks/use-storage-persistence.ts b/apps/web/src/hooks/use-storage-persistence.ts
new file mode 100644
index 00000000..79e05069
--- /dev/null
+++ b/apps/web/src/hooks/use-storage-persistence.ts
@@ -0,0 +1,45 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+const DISMISSED_KEY = "opencut-storage-persist-dismissed";
+
+function isFirefox(): boolean {
+ return navigator.userAgent.toLowerCase().includes("firefox");
+}
+
+export function useStoragePersistence() {
+ const [showDialog, setShowDialog] = useState(false);
+
+ useEffect(() => {
+ if (!navigator.storage?.persist) return;
+
+ const run = async () => {
+ const alreadyPersisted = await navigator.storage.persisted();
+ if (alreadyPersisted) return;
+
+ const dismissed = localStorage.getItem(DISMISSED_KEY) === "true";
+ if (dismissed) return;
+
+ if (isFirefox()) {
+ setShowDialog(true);
+ } else {
+ await navigator.storage.persist();
+ }
+ };
+
+ run();
+ }, []);
+
+ const onConfirm = async () => {
+ setShowDialog(false);
+ await navigator.storage.persist();
+ };
+
+ const onDismiss = () => {
+ setShowDialog(false);
+ localStorage.setItem(DISMISSED_KEY, "true");
+ };
+
+ return { showDialog, onConfirm, onDismiss };
+}
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
index fb561396..a01fb361 100644
--- a/apps/web/src/hooks/use-transform-handles.ts
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -1,4 +1,6 @@
-import { useCallback, useRef, useState, useSyncExternalStore } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import {
@@ -6,14 +8,12 @@ import {
type ElementWithBounds,
} from "@/lib/preview/element-bounds";
import {
- screenPixelsToLogicalThreshold,
- screenToCanvas,
-} from "@/lib/preview/preview-coords";
-import {
MIN_SCALE,
SNAP_THRESHOLD_SCREEN_PIXELS,
snapRotation,
snapScale,
+ snapScaleAxes,
+ type ScaleEdgePreference,
type SnapLine,
} from "@/lib/preview/preview-snap";
import { isVisualElement } from "@/lib/timeline/element-utils";
@@ -22,11 +22,25 @@ import {
resolveTransformAtTime,
setChannel,
} from "@/lib/animation";
-import type { Transform } from "@/types/timeline";
-import type { ElementAnimations } from "@/types/animation";
+import type { Transform } from "@/lib/rendering";
+import type { ElementAnimations } from "@/lib/animation/types";
+import { registerCanceller } from "@/lib/cancel-interaction";
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
-type HandleType = Corner | "rotation";
+type Edge = "right" | "left" | "bottom";
+type HandleType = Corner | Edge | "rotation";
+
+function getPreferredEdge({
+ edge,
+}: {
+ edge: Edge;
+}): ScaleEdgePreference {
+ return edge === "right"
+ ? { right: true }
+ : edge === "left"
+ ? { left: true }
+ : { bottom: true };
+}
interface ScaleState {
trackId: string;
@@ -50,26 +64,25 @@ interface RotationState {
initialBoundsCy: number;
}
-function areSnapLinesEqual({
- previousLines,
- nextLines,
-}: {
- previousLines: SnapLine[];
- nextLines: SnapLine[];
-}): boolean {
- if (previousLines.length !== nextLines.length) {
- return false;
- }
- for (const [index, line] of previousLines.entries()) {
- const nextLine = nextLines[index];
- if (!nextLine) {
- return false;
- }
- if (line.type !== nextLine.type || line.position !== nextLine.position) {
- return false;
- }
+interface EdgeScaleState {
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ initialBoundsCx: number;
+ initialBoundsCy: number;
+ baseWidth: number;
+ baseHeight: number;
+ edge: Edge;
+ rotationRad: number;
+ shouldClearScaleAnimation: boolean;
+ animationsWithoutScale: ElementAnimations | undefined;
+}
+
+function clampScaleNonZero(scale: number): number {
+ if (Math.abs(scale) < MIN_SCALE) {
+ return scale < 0 ? -MIN_SCALE : MIN_SCALE;
}
- return true;
+ return scale;
}
function getCornerDistance({
@@ -102,29 +115,30 @@ function getCornerDistance({
}
export function useTransformHandles({
- canvasRef,
+ onSnapLinesChange,
}: {
- canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ onSnapLinesChange?: OnSnapLinesChange;
}) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
+ const viewport = usePreviewViewport();
const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
- const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
- const snapLinesRef = useRef<SnapLine[]>([]);
const scaleStateRef = useRef<ScaleState | null>(null);
const rotationStateRef = useRef<RotationState | null>(null);
-
- const selectedElements = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedElements(),
+ const edgeScaleStateRef = useRef<EdgeScaleState | null>(null);
+ const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ null,
);
- const tracks = editor.timeline.getTracks();
- const currentTime = editor.playback.getCurrentTime();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const tracks = useEditor((e) => e.timeline.getRenderTracks());
+ const currentTime = useEditor((e) => e.playback.getCurrentTime());
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
- const mediaAssets = editor.media.getAssets();
- const canvasSize = editor.project.getActive().settings.canvasSize;
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const canvasSize = useEditor(
+ (e) => e.project.getActive().settings.canvasSize,
+ );
const elementsWithBounds = getVisibleElementsWithBounds({
tracks,
@@ -145,6 +159,37 @@ export function useTransformHandles({
const hasVisualSelection =
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
+ const clearActiveHandleState = useCallback(() => {
+ scaleStateRef.current = null;
+ rotationStateRef.current = null;
+ edgeScaleStateRef.current = null;
+ setActiveHandle(null);
+ onSnapLinesChange?.([]);
+ }, [onSnapLinesChange]);
+
+ const releaseCapturedPointer = useCallback(() => {
+ const capture = captureRef.current;
+ if (!capture) return;
+
+ if (capture.element.hasPointerCapture(capture.pointerId)) {
+ capture.element.releasePointerCapture(capture.pointerId);
+ }
+
+ captureRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!activeHandle) return;
+
+ return registerCanceller({
+ fn: () => {
+ editor.timeline.discardPreview();
+ clearActiveHandleState();
+ releaseCapturedPointer();
+ },
+ });
+ }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]);
+
const handleCornerPointerDown = useCallback(
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
if (!selectedWithBounds) return;
@@ -165,14 +210,19 @@ export function useTransformHandles({
});
const initialDistance = getCornerDistance({ bounds, corner });
- const baseWidth = bounds.width / resolvedTransform.scale;
- const baseHeight = bounds.height / resolvedTransform.scale;
+ const baseWidth = bounds.width / resolvedTransform.scaleX;
+ const baseHeight = bounds.height / resolvedTransform.scaleY;
const shouldClearScaleAnimation =
- !!element.animations?.channels["transform.scale"];
+ !!element.animations?.channels["transform.scaleX"] ||
+ !!element.animations?.channels["transform.scaleY"];
const animationsWithoutScale = shouldClearScaleAnimation
? setChannel({
- animations: element.animations,
- propertyPath: "transform.scale",
+ animations: setChannel({
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ channel: undefined,
+ }),
+ propertyPath: "transform.scaleY",
channel: undefined,
})
: element.animations;
@@ -190,14 +240,19 @@ export function useTransformHandles({
animationsWithoutScale,
};
setActiveHandle(corner);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
},
[selectedWithBounds],
);
const handleRotationPointerDown = useCallback(
({ event }: { event: React.PointerEvent }) => {
- if (!selectedWithBounds || !canvasRef.current) return;
+ if (!selectedWithBounds) return;
event.stopPropagation();
const { bounds, trackId, elementId, element } = selectedWithBounds;
@@ -214,14 +269,14 @@ export function useTransformHandles({
localTime,
});
- const position = screenToCanvas({
+ const position = viewport.screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
- canvas: canvasRef.current,
});
- const deltaX = position.x - bounds.cx;
- const deltaY = position.y - bounds.cy;
- const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ if (!position) return;
+ const deltaX = position.x - bounds.cx;
+ const deltaY = position.y - bounds.cy;
+ const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
rotationStateRef.current = {
trackId,
@@ -232,21 +287,91 @@ export function useTransformHandles({
initialBoundsCy: bounds.cy,
};
setActiveHandle("rotation");
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
},
- [selectedWithBounds, canvasRef],
+ [selectedWithBounds, viewport],
+ );
+
+ const handleEdgePointerDown = useCallback(
+ ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
+ if (!selectedWithBounds) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const localTime = getElementLocalTime({
+ timelineTime: currentTimeRef.current,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const baseWidth = bounds.width / resolvedTransform.scaleX;
+ const baseHeight = bounds.height / resolvedTransform.scaleY;
+ const rotationRad = (bounds.rotation * Math.PI) / 180;
+
+ const propertyPath =
+ edge === "right" || edge === "left"
+ ? "transform.scaleX"
+ : "transform.scaleY";
+ const shouldClearScaleAnimation =
+ !!element.animations?.channels[propertyPath];
+ const animationsWithoutScale = shouldClearScaleAnimation
+ ? setChannel({
+ animations: element.animations,
+ propertyPath,
+ channel: undefined,
+ })
+ : element.animations;
+
+ edgeScaleStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: resolvedTransform,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ baseWidth,
+ baseHeight,
+ edge,
+ rotationRad,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ };
+ setActiveHandle(edge);
+ const captureTarget = event.currentTarget as HTMLElement;
+ captureTarget.setPointerCapture(event.pointerId);
+ captureRef.current = {
+ element: captureTarget,
+ pointerId: event.pointerId,
+ };
+ },
+ [selectedWithBounds],
);
const handlePointerMove = useCallback(
({ event }: { event: React.PointerEvent }) => {
- if (!canvasRef.current) return;
- if (!scaleStateRef.current && !rotationStateRef.current) return;
+ if (
+ !scaleStateRef.current &&
+ !rotationStateRef.current &&
+ !edgeScaleStateRef.current
+ )
+ return;
- const position = screenToCanvas({
+ const position = viewport.screenToCanvas({
clientX: event.clientX,
clientY: event.clientY,
- canvas: canvasRef.current,
});
+ if (!position) return;
if (
scaleStateRef.current &&
@@ -266,58 +391,157 @@ export function useTransformHandles({
animationsWithoutScale,
} = scaleStateRef.current;
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentDistance =
+ Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
const scaleFactor = currentDistance / initialDistance;
- const proposedScale = Math.max(
- MIN_SCALE,
- initialTransform.scale * scaleFactor,
- );
- const canvasSize = editor.project.getActive().settings.canvasSize;
- const snapThreshold = screenPixelsToLogicalThreshold({
- canvas: canvasRef.current,
+ // Use actual element dimensions (base * current scale) so snap
+ // computes the correct edges when scaleX ≠ scaleY
+ const effectiveWidth = baseWidth * initialTransform.scaleX;
+ const effectiveHeight = baseHeight * initialTransform.scaleY;
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
- const shouldSnap = !isShiftHeldRef.current;
- const { snappedScale, activeLines } = shouldSnap
- ? snapScale({
- proposedScale,
+ const { snappedScale: snappedFactor, activeLines } =
+ isShiftHeldRef.current
+ ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
+ : snapScale({
+ proposedScale: scaleFactor,
+ position: initialTransform.position,
+ baseWidth: effectiveWidth,
+ baseHeight: effectiveHeight,
+ rotation: initialTransform.rotate,
+ canvasSize,
+ snapThreshold,
+ });
+
+ onSnapLinesChange?.(activeLines);
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX: clampScaleNonZero(
+ initialTransform.scaleX * snappedFactor,
+ ),
+ scaleY: clampScaleNonZero(
+ initialTransform.scaleY * snappedFactor,
+ ),
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ if (
+ edgeScaleStateRef.current &&
+ (activeHandle === "right" ||
+ activeHandle === "left" ||
+ activeHandle === "bottom")
+ ) {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialBoundsCx,
+ initialBoundsCy,
+ baseWidth,
+ baseHeight,
+ edge,
+ rotationRad,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ } = edgeScaleStateRef.current;
+
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const xProjection =
+ deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
+ const yProjection =
+ -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
+ const projection =
+ edge === "right"
+ ? xProjection
+ : edge === "left"
+ ? -xProjection
+ : yProjection;
+
+ const baseAxisHalf =
+ edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
+ const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
+
+ const proposedScaleX =
+ edge === "right" || edge === "left"
+ ? proposedScale
+ : initialTransform.scaleX;
+ const proposedScaleY =
+ edge === "bottom" ? proposedScale : initialTransform.scaleY;
+
+ const snapThreshold = viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { x: xSnap, y: ySnap } = isShiftHeldRef.current
+ ? {
+ x: {
+ snappedScale: proposedScaleX,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ y: {
+ snappedScale: proposedScaleY,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ }
+ : snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY,
position: initialTransform.position,
baseWidth,
baseHeight,
+ rotation: initialTransform.rotate,
canvasSize,
snapThreshold,
- })
- : { snappedScale: proposedScale, activeLines: [] as SnapLine[] };
-
- const isSameLines = areSnapLinesEqual({
- previousLines: snapLinesRef.current,
- nextLines: activeLines,
- });
-
- if (!isSameLines) {
- snapLinesRef.current = activeLines;
- setSnapLines(activeLines);
- }
+ preferredEdges: getPreferredEdge({ edge }),
+ });
- const updates: {
- transform: Transform;
- animations?: ElementAnimations;
- } = {
- transform: { ...initialTransform, scale: snappedScale },
- };
- if (shouldClearScaleAnimation) {
- updates.animations = animationsWithoutScale;
- }
+ const relevantSnap =
+ edge === "right" || edge === "left" ? xSnap : ySnap;
+ onSnapLinesChange?.(relevantSnap.activeLines);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
- updates,
+ updates: {
+ transform: {
+ ...initialTransform,
+ scaleX:
+ edge === "right" || edge === "left"
+ ? xSnap.snappedScale
+ : initialTransform.scaleX,
+ scaleY:
+ edge === "bottom"
+ ? ySnap.snappedScale
+ : initialTransform.scaleY,
+ },
+ ...(shouldClearScaleAnimation && {
+ animations: animationsWithoutScale,
+ }),
+ },
},
],
});
@@ -334,17 +558,16 @@ export function useTransformHandles({
initialBoundsCy,
} = rotationStateRef.current;
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ const deltaX = position.x - initialBoundsCx;
+ const deltaY = position.y - initialBoundsCy;
+ const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
let deltaAngle = currentAngle - initialAngle;
if (deltaAngle > 180) deltaAngle -= 360;
if (deltaAngle < -180) deltaAngle += 360;
const newRotate = initialTransform.rotate + deltaAngle;
- const shouldSnapRotation = !isShiftHeldRef.current;
- const { snappedRotation } = shouldSnapRotation
- ? snapRotation({ proposedRotation: newRotate })
- : { snappedRotation: newRotate };
+ const { snappedRotation } = isShiftHeldRef.current
+ ? { snappedRotation: newRotate }
+ : snapRotation({ proposedRotation: newRotate });
editor.timeline.previewElements({
updates: [
@@ -359,32 +582,36 @@ export function useTransformHandles({
});
}
},
- [activeHandle, canvasRef, editor, isShiftHeldRef],
+ [
+ activeHandle,
+ canvasSize,
+ editor,
+ isShiftHeldRef,
+ onSnapLinesChange,
+ viewport,
+ ],
);
- const handlePointerUp = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (scaleStateRef.current || rotationStateRef.current) {
+ const handlePointerUp = useCallback(() => {
+ if (
+ scaleStateRef.current ||
+ rotationStateRef.current ||
+ edgeScaleStateRef.current
+ ) {
editor.timeline.commitPreview();
- scaleStateRef.current = null;
- rotationStateRef.current = null;
- setActiveHandle(null);
- snapLinesRef.current = [];
- setSnapLines([]);
+ clearActiveHandleState();
}
- (event.currentTarget as HTMLElement).releasePointerCapture(
- event.pointerId,
- );
+ releaseCapturedPointer();
},
- [editor],
+ [clearActiveHandleState, editor, releaseCapturedPointer],
);
return {
selectedWithBounds,
hasVisualSelection,
activeHandle,
- snapLines,
handleCornerPointerDown,
+ handleEdgePointerDown,
handleRotationPointerDown,
handlePointerMove,
handlePointerUp,
diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts
index 82319216..4d67c1ef 100644
--- a/apps/web/src/lib/actions/definitions.ts
+++ b/apps/web/src/lib/actions/definitions.ts
@@ -1,4 +1,4 @@
-import type { ShortcutKey } from "@/types/keybinding";
+import type { ShortcutKey } from "@/lib/actions/keybinding";
export type TActionCategory =
| "playback"
@@ -7,7 +7,8 @@ export type TActionCategory =
| "selection"
| "history"
| "timeline"
- | "controls";
+ | "controls"
+ | "assets";
export interface TActionDefinition {
description: string;
@@ -114,10 +115,14 @@ export const ACTIONS = {
category: "selection",
defaultShortcuts: ["ctrl+a"],
},
+ "cancel-interaction": {
+ description: "Cancel current interaction",
+ category: "controls",
+ defaultShortcuts: ["escape"],
+ },
"deselect-all": {
description: "Deselect all elements",
category: "selection",
- defaultShortcuts: ["escape"],
},
"duplicate-selected": {
description: "Duplicate selected element",
@@ -146,11 +151,25 @@ export const ACTIONS = {
category: "history",
defaultShortcuts: ["ctrl+shift+z", "ctrl+y"],
},
+ "remove-media-asset": {
+ description: "Remove media asset",
+ category: "assets",
+ args: { projectId: "string", assetId: "string" },
+ },
+ "remove-media-assets": {
+ description: "Remove media assets",
+ category: "assets",
+ args: { projectId: "string", assetIds: "string[]" },
+ },
} as const satisfies Record<string, TActionDefinition>;
export type TAction = keyof typeof ACTIONS;
-export function getActionDefinition({ action }: { action: TAction }): TActionDefinition {
+export function getActionDefinition({
+ action,
+}: {
+ action: TAction;
+}): TActionDefinition {
return ACTIONS[action];
}
diff --git a/apps/web/src/types/keybinding.ts b/apps/web/src/lib/actions/keybinding.ts
index f9b84e31..0a61893b 100644
--- a/apps/web/src/types/keybinding.ts
+++ b/apps/web/src/lib/actions/keybinding.ts
@@ -1,4 +1,4 @@
-import type { TActionWithOptionalArgs } from "@/lib/actions";
+import type { TActionWithOptionalArgs } from "./types";
/**
* Alt is also regarded as macOS OPTION (⌥) key
diff --git a/apps/web/src/lib/actions/registry.ts b/apps/web/src/lib/actions/registry.ts
index b9c84529..c825a5e4 100644
--- a/apps/web/src/lib/actions/registry.ts
+++ b/apps/web/src/lib/actions/registry.ts
@@ -57,5 +57,30 @@ export const invokeAction: InvokeActionFunc = <A extends TAction>(
args?: TArgOfAction<A>,
trigger?: TInvocationTrigger,
) => {
- boundActions[action]?.forEach((handler) => handler(args, trigger));
+ if (trigger === "keypress") {
+ // #region agent log
+ fetch("http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H4",
+ location: "actions/registry.ts:invokeAction",
+ message: "Action invoked from keypress",
+ data: {
+ action,
+ handlerCount: boundActions[action]?.length ?? 0,
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
+ }
+ boundActions[action]?.forEach((handler) => {
+ handler(args, trigger);
+ });
};
diff --git a/apps/web/src/lib/actions/types.ts b/apps/web/src/lib/actions/types.ts
index 508bf10e..38627b95 100644
--- a/apps/web/src/lib/actions/types.ts
+++ b/apps/web/src/lib/actions/types.ts
@@ -8,6 +8,8 @@ export type TActionArgsMap = {
"seek-backward": { seconds: number } | undefined;
"jump-forward": { seconds: number } | undefined;
"jump-backward": { seconds: number } | undefined;
+ "remove-media-asset": { projectId: string; assetId: string };
+ "remove-media-assets": { projectId: string; assetIds: string[] };
};
type TKeysWithValueUndefined<T> = {
diff --git a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts
deleted file mode 100644
index 629bd0d6..00000000
--- a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts
+++ /dev/null
@@ -1,313 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type { ElementAnimations } from "@/types/animation";
-import {
- clampAnimationsToDuration,
- getElementKeyframes,
- getKeyframeAtTime,
- hasKeyframesForPath,
- getChannelValueAtTime,
- getElementLocalTime,
- resolveTransformAtTime,
- splitAnimationsAtTime,
-} from "@/lib/animation";
-
-describe("transform keyframe evaluation", () => {
- test("uses fallback value when channel is missing", () => {
- const value = getChannelValueAtTime({
- channel: undefined,
- time: 1,
- fallbackValue: 42,
- });
- expect(value).toBe(42);
- });
-
- test("returns boundary value when time is within epsilon of first/last keyframe", () => {
- const channel = {
- valueKind: "number" as const,
- keyframes: [
- { id: "a", time: 0, value: 10, interpolation: "linear" as const },
- { id: "b", time: 2, value: 30, interpolation: "linear" as const },
- ],
- };
- expect(
- getChannelValueAtTime({
- channel,
- time: 0.0008,
- fallbackValue: 0,
- }),
- ).toBe(10);
- expect(
- getChannelValueAtTime({
- channel,
- time: 1.9992,
- fallbackValue: 0,
- }),
- ).toBe(30);
- });
-
- test("interpolates linear channels", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 10, interpolation: "linear" },
- { id: "b", time: 2, value: 30, interpolation: "linear" },
- ],
- },
- time: 1,
- fallbackValue: 0,
- });
- expect(value).toBe(20);
- });
-
- test("clamps local time to [0, duration]", () => {
- expect(
- getElementLocalTime({
- timelineTime: 2,
- elementStartTime: 5,
- elementDuration: 4,
- }),
- ).toBe(0);
- expect(
- getElementLocalTime({
- timelineTime: 12,
- elementStartTime: 5,
- elementDuration: 4,
- }),
- ).toBe(4);
- expect(
- getElementLocalTime({
- timelineTime: 7,
- elementStartTime: 5,
- elementDuration: 4,
- }),
- ).toBe(2);
- });
-
- test("uses hold interpolation from the left keyframe", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 10, interpolation: "hold" },
- { id: "b", time: 2, value: 30, interpolation: "linear" },
- ],
- },
- time: 1,
- fallbackValue: 0,
- });
- expect(value).toBe(10);
- });
-
- test("resolves transform by mixing animated and fallback properties", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.position.x": {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 0, interpolation: "linear" },
- { id: "b", time: 4, value: 80, interpolation: "linear" },
- ],
- },
- "transform.scale": {
- valueKind: "number",
- keyframes: [{ id: "c", time: 0, value: 2, interpolation: "hold" }],
- },
- },
- };
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: {
- position: { x: 10, y: 20 },
- scale: 1,
- rotate: 15,
- },
- animations,
- localTime: 2,
- });
- expect(resolvedTransform).toEqual({
- position: { x: 40, y: 20 },
- scale: 2,
- rotate: 15,
- });
- });
-});
-
-describe("transform keyframe mutation utilities", () => {
- test("splits channels and rebases right side times", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.scale": {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 1, interpolation: "linear" },
- { id: "b", time: 2, value: 2, interpolation: "linear" },
- { id: "c", time: 6, value: 4, interpolation: "linear" },
- ],
- },
- },
- };
- const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
- animations,
- splitTime: 4,
- });
-
- expect(
- leftAnimations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2, 4]);
- expect(
- rightAnimations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2]);
- expect(
- rightAnimations?.channels["transform.scale"]?.keyframes[0]?.value,
- ).toBe(3);
- });
-
- test("clamps channels to updated element duration", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.rotate": {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 0, interpolation: "linear" },
- { id: "b", time: 2, value: 20, interpolation: "linear" },
- { id: "c", time: 5, value: 50, interpolation: "linear" },
- ],
- },
- },
- };
- const clampedAnimations = clampAnimationsToDuration({
- animations,
- duration: 2,
- });
- expect(
- clampedAnimations?.channels["transform.rotate"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2]);
- });
-});
-
-describe("typed channel interpolation", () => {
- test("interpolates color channels from hex keyframes", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "color",
- keyframes: [
- { id: "a", time: 0, value: "#000000", interpolation: "linear" },
- { id: "b", time: 1, value: "#ffffff", interpolation: "linear" },
- ],
- },
- time: 0.5,
- fallbackValue: "#000000",
- });
- expect(typeof value).toBe("string");
- expect(value).toContain("rgba(");
- });
-
- test("uses hold behavior for discrete channels", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "discrete",
- keyframes: [
- { id: "a", time: 0, value: "normal", interpolation: "hold" },
- { id: "b", time: 2, value: "multiply", interpolation: "hold" },
- ],
- },
- time: 1.2,
- fallbackValue: "normal",
- });
- expect(value).toBe("normal");
- });
-});
-
-describe("keyframe query helpers", () => {
- test("getElementKeyframes returns flat list of all keyframes across channels", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.position.x": {
- valueKind: "number",
- keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
- },
- opacity: {
- valueKind: "number",
- keyframes: [{ id: "o-1", time: 0, value: 1, interpolation: "linear" }],
- },
- },
- };
-
- const keyframes = getElementKeyframes({ animations });
- expect(keyframes).toHaveLength(2);
- expect(keyframes.map((keyframe) => keyframe.propertyPath).sort()).toEqual([
- "opacity",
- "transform.position.x",
- ]);
- });
-
- test("getElementKeyframes returns empty array when animations are missing or channels are empty", () => {
- expect(getElementKeyframes({ animations: undefined })).toEqual([]);
- expect(
- getElementKeyframes({
- animations: {
- channels: { opacity: { valueKind: "number", keyframes: [] } },
- },
- }),
- ).toEqual([]);
- });
-
- test("hasKeyframesForPath returns true only for paths with keyframes", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.position.x": {
- valueKind: "number",
- keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
- },
- "transform.position.y": {
- valueKind: "number",
- keyframes: [],
- },
- },
- };
-
- expect(
- hasKeyframesForPath({ animations, propertyPath: "transform.position.x" }),
- ).toBe(true);
- expect(
- hasKeyframesForPath({ animations, propertyPath: "transform.position.y" }),
- ).toBe(false);
- });
-
- test("getKeyframeAtTime finds keyframe within epsilon and returns full object", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.rotate": {
- valueKind: "number",
- keyframes: [
- { id: "r-1", time: 1, value: 15, interpolation: "linear" },
- { id: "r-2", time: 2, value: 30, interpolation: "linear" },
- ],
- },
- },
- };
-
- const found = getKeyframeAtTime({
- animations,
- propertyPath: "transform.rotate",
- time: 1.0008,
- });
- expect(found?.id).toBe("r-1");
- expect(found?.value).toBe(15);
- expect(found?.propertyPath).toBe("transform.rotate");
-
- expect(
- getKeyframeAtTime({
- animations,
- propertyPath: "transform.rotate",
- time: 1.01,
- }),
- ).toBeNull();
- });
-});
diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts
index 2a340f94..a34b469b 100644
--- a/apps/web/src/lib/animation/color-channel.ts
+++ b/apps/web/src/lib/animation/color-channel.ts
@@ -2,7 +2,7 @@ import type {
AnimationPropertyPath,
ColorAnimationChannel,
ElementAnimations,
-} from "@/types/animation";
+} from "@/lib/animation/types";
export function getColorChannelForPath({
animations,
diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts
index 07efe4d8..323d9f54 100644
--- a/apps/web/src/lib/animation/effect-param-channel.ts
+++ b/apps/web/src/lib/animation/effect-param-channel.ts
@@ -1,8 +1,10 @@
-import type { Effect, EffectParamValues } from "@/types/effects";
+import type { ParamValues } from "@/lib/params";
+import type { Effect } from "@/lib/effects/types";
import type {
ElementAnimations,
+ EffectParamPath,
NumberAnimationChannel,
-} from "@/types/animation";
+} from "@/lib/animation/types";
import {
getChannel,
removeKeyframe,
@@ -11,19 +13,56 @@ import {
} from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
-const EFFECT_PARAM_PATH_PREFIX = "effects.";
-const EFFECT_PARAM_PATH_SUFFIX = ".params.";
+export const EFFECT_PARAM_PATH_PREFIX = "effects.";
+export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
-function buildEffectParamPath({
+export function buildEffectParamPath({
effectId,
paramKey,
}: {
effectId: string;
paramKey: string;
-}): string {
+}): EffectParamPath {
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
}
+export function isEffectParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): propertyPath is EffectParamPath {
+ return (
+ propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) &&
+ propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX)
+ );
+}
+
+export function parseEffectParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): { effectId: string; paramKey: string } | null {
+ if (!isEffectParamPath({ propertyPath })) {
+ return null;
+ }
+
+ const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length);
+ const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX);
+ if (separatorIndex <= 0) {
+ return null;
+ }
+
+ const effectId = withoutPrefix.slice(0, separatorIndex);
+ const paramKey = withoutPrefix.slice(
+ separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length,
+ );
+ if (!effectId || !paramKey) {
+ return null;
+ }
+
+ return { effectId, paramKey };
+}
+
export function resolveEffectParamsAtTime({
effect,
animations,
@@ -32,8 +71,8 @@ export function resolveEffectParamsAtTime({
effect: Effect;
animations: ElementAnimations | undefined;
localTime: number;
-}): EffectParamValues {
- const resolved: EffectParamValues = {};
+}): ParamValues {
+ const resolved: ParamValues = {};
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts
new file mode 100644
index 00000000..e77e4c73
--- /dev/null
+++ b/apps/web/src/lib/animation/graphic-param-channel.ts
@@ -0,0 +1,79 @@
+import type {
+ ElementAnimations,
+ GraphicParamPath,
+} from "@/lib/animation/types";
+import type { ParamValues } from "@/lib/params";
+import {
+ getGraphicDefinition,
+ resolveGraphicParams,
+} from "@/lib/graphics";
+import { getChannel } from "./keyframes";
+import { getChannelValueAtTime } from "./interpolation";
+
+export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
+
+export function buildGraphicParamPath({
+ paramKey,
+}: {
+ paramKey: string;
+}): GraphicParamPath {
+ return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
+}
+
+export function isGraphicParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): propertyPath is GraphicParamPath {
+ return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
+}
+
+export function parseGraphicParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): { paramKey: string } | null {
+ if (!isGraphicParamPath({ propertyPath })) {
+ return null;
+ }
+
+ const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
+ return paramKey.length > 0 ? { paramKey } : null;
+}
+
+export function resolveGraphicParamsAtTime({
+ element,
+ localTime,
+}: {
+ element: {
+ definitionId: string;
+ params: ParamValues;
+ animations?: ElementAnimations;
+ };
+ localTime: number;
+}): ParamValues {
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const baseParams = resolveGraphicParams(definition, element.params);
+ const resolved: ParamValues = { ...baseParams };
+
+ for (const param of definition.params) {
+ const path = buildGraphicParamPath({ paramKey: param.key });
+ const channel = getChannel({
+ animations: element.animations,
+ propertyPath: path,
+ });
+ if (!channel || channel.keyframes.length === 0) {
+ continue;
+ }
+
+ resolved[param.key] = getChannelValueAtTime({
+ channel,
+ time: Math.max(0, localTime),
+ fallbackValue: baseParams[param.key] ?? param.default,
+ }) as number | string | boolean;
+ }
+
+ return resolved;
+}
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
index c4396bb7..3dcddad3 100644
--- a/apps/web/src/lib/animation/index.ts
+++ b/apps/web/src/lib/animation/index.ts
@@ -1,6 +1,7 @@
export {
getChannelValueAtTime,
getNumberChannelValueAtTime,
+ getVectorChannelValueAtTime,
normalizeChannel,
} from "./interpolation";
@@ -13,6 +14,7 @@ export {
setChannel,
splitAnimationsAtTime,
upsertElementKeyframe,
+ upsertPathKeyframe,
} from "./keyframes";
export {
@@ -21,7 +23,6 @@ export {
resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
- resolveVolumeAtTime,
} from "./resolve";
export {
@@ -31,6 +32,9 @@ export {
getElementBaseValueForProperty,
isAnimationPropertyPath,
supportsAnimationProperty,
+ type AnimationPropertyDefinition,
+ type NumericSpec,
+ type NumericRange,
withElementBaseValueForProperty,
} from "./property-registry";
@@ -39,3 +43,29 @@ export {
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";
+
+export {
+ buildGraphicParamPath,
+ isGraphicParamPath,
+ parseGraphicParamPath,
+ resolveGraphicParamsAtTime,
+} from "./graphic-param-channel";
+
+export {
+ isAnimationPath,
+ resolveAnimationTarget,
+ getParamValueKind,
+ getParamDefaultInterpolation,
+ type AnimationPathDescriptor,
+} from "./target-resolver";
+
+export {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ type GroupKeyframeRef,
+} from "./property-groups";
+
+export {
+ getVectorChannelForPath,
+ isVectorValue,
+} from "./vector-channel";
diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
index 7a6fc6cf..c5b9ea80 100644
--- a/apps/web/src/lib/animation/interpolation.ts
+++ b/apps/web/src/lib/animation/interpolation.ts
@@ -5,7 +5,10 @@ import type {
DiscreteValue,
DiscreteAnimationChannel,
NumberAnimationChannel,
-} from "@/types/animation";
+ VectorAnimationChannel,
+ VectorValue,
+} from "@/lib/animation/types";
+import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
function byTimeAscending({
@@ -59,12 +62,7 @@ function parseHexColor({
const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
- if (
- red === null ||
- green === null ||
- blue === null ||
- alpha === null
- ) {
+ if (red === null || green === null || blue === null || alpha === null) {
return null;
}
@@ -77,12 +75,7 @@ function parseHexColor({
const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
const alpha = parseHexChannel({ hex: alphaHex });
- if (
- red === null ||
- green === null ||
- blue === null ||
- alpha === null
- ) {
+ if (red === null || green === null || blue === null || alpha === null) {
return null;
}
@@ -177,7 +170,10 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
} as TChannel;
}
-function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TValue }, TValue>({
+function evaluateChannelValueAtTime<
+ TKeyframe extends { time: number; value: TValue },
+ TValue,
+>({
keyframes,
time,
fallbackValue,
@@ -214,7 +210,11 @@ function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TVa
return lastKeyframe.value;
}
- for (let keyframeIndex = 0; keyframeIndex < keyframes.length - 1; keyframeIndex++) {
+ for (
+ let keyframeIndex = 0;
+ keyframeIndex < keyframes.length - 1;
+ keyframeIndex++
+ ) {
const leftKeyframe = keyframes[keyframeIndex];
const rightKeyframe = keyframes[keyframeIndex + 1];
@@ -304,6 +304,36 @@ export function getColorValueAtTime({
});
}
+export function getVectorChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: VectorAnimationChannel | undefined;
+ time: number;
+ fallbackValue: VectorValue;
+}): VectorValue {
+ return evaluateChannelValueAtTime({
+ keyframes: channel?.keyframes,
+ time,
+ fallbackValue,
+ getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
+ if (leftKeyframe.interpolation === "hold") {
+ return leftKeyframe.value;
+ }
+
+ return {
+ x:
+ leftKeyframe.value.x +
+ (rightKeyframe.value.x - leftKeyframe.value.x) * progress,
+ y:
+ leftKeyframe.value.y +
+ (rightKeyframe.value.y - leftKeyframe.value.y) * progress,
+ };
+ },
+ });
+}
+
function getDiscreteValueAtTime({
channel,
time,
@@ -358,6 +388,17 @@ export function getChannelValueAtTime({
});
}
+ if (channel.valueKind === "vector") {
+ if (!isVectorValue(fallbackValue)) {
+ return fallbackValue;
+ }
+ return getVectorChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue: fallbackValue as VectorValue,
+ });
+ }
+
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
return fallbackValue;
}
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
index 6626e3e8..753e31d0 100644
--- a/apps/web/src/lib/animation/keyframe-query.ts
+++ b/apps/web/src/lib/animation/keyframe-query.ts
@@ -1,9 +1,10 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type {
- AnimationPropertyPath,
+ AnimationPath,
ElementAnimations,
ElementKeyframe,
-} from "@/types/animation";
+} from "@/lib/animation/types";
+import { isAnimationPath } from "./target-resolver";
export function getElementKeyframes({
animations,
@@ -16,12 +17,16 @@ export function getElementKeyframes({
return Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
- if (!channel || channel.keyframes.length === 0) {
+ if (
+ !channel ||
+ channel.keyframes.length === 0 ||
+ !isAnimationPath({ propertyPath })
+ ) {
return [];
}
return channel.keyframes.map((keyframe) => ({
- propertyPath: propertyPath as AnimationPropertyPath,
+ propertyPath,
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
@@ -36,7 +41,7 @@ export function hasKeyframesForPath({
propertyPath,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
}): boolean {
const channel = animations?.channels[propertyPath];
return Boolean(channel && channel.keyframes.length > 0);
@@ -48,7 +53,7 @@ export function getKeyframeAtTime({
time,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
const channel = animations?.channels[propertyPath];
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
index 3e3613f2..5fa99fb8 100644
--- a/apps/web/src/lib/animation/keyframes.ts
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -2,6 +2,7 @@ import type {
AnimationChannel,
AnimationInterpolation,
AnimationKeyframe,
+ AnimationPath,
AnimationPropertyPath,
AnimationValue,
AnimationValueKind,
@@ -9,15 +10,19 @@ import type {
DiscreteAnimationChannel,
ElementAnimations,
NumberAnimationChannel,
-} from "@/types/animation";
+ VectorAnimationChannel,
+} from "@/lib/animation/types";
+import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { generateUUID } from "@/utils/id";
+import { snapToStep } from "@/utils/math";
import { getChannelValueAtTime, normalizeChannel } from "./interpolation";
import {
coerceAnimationValueForProperty,
getDefaultInterpolationForProperty,
getAnimationPropertyDefinition,
isAnimationPropertyPath,
+ type NumericRange,
} from "./property-registry";
function isNearlySameTime({
@@ -126,6 +131,19 @@ function buildKeyframe({
};
}
+ if (channel.valueKind === "vector") {
+ if (!isVectorValue(value)) {
+ throw new Error("Vector channel keyframes require {x, y} values");
+ }
+
+ return {
+ id,
+ time,
+ value,
+ interpolation: interpolation === "hold" ? "hold" : "linear",
+ };
+ }
+
if (typeof value !== "string" && typeof value !== "boolean") {
throw new Error(
"Discrete channel keyframes require boolean or string values",
@@ -140,32 +158,178 @@ function buildKeyframe({
};
}
-function createEmptyChannel({
- propertyPath,
+function createEmptyChannelForValueKind({
+ valueKind,
}: {
- propertyPath: AnimationPropertyPath;
+ valueKind: AnimationValueKind;
}): AnimationChannel {
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- if (propertyDefinition.valueKind === "number") {
+ if (valueKind === "number") {
return {
valueKind: "number",
keyframes: [],
} satisfies NumberAnimationChannel;
}
- if (propertyDefinition.valueKind === "color") {
+ if (valueKind === "color") {
return {
valueKind: "color",
keyframes: [],
} satisfies ColorAnimationChannel;
}
+ if (valueKind === "vector") {
+ return {
+ valueKind: "vector",
+ keyframes: [],
+ } satisfies VectorAnimationChannel;
+ }
+
return {
valueKind: "discrete",
keyframes: [],
} satisfies DiscreteAnimationChannel;
}
+function clampNumericRange({
+ value,
+ numericRange,
+}: {
+ value: number;
+ numericRange: NumericRange | undefined;
+}): number {
+ if (!numericRange) {
+ return value;
+ }
+
+ const steppedValue =
+ numericRange.step != null
+ ? snapToStep({ value, step: numericRange.step })
+ : value;
+ const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
+ const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+}
+
+function coerceAnimationValueForPath({
+ value,
+ valueKind,
+ numericRange,
+}: {
+ value: AnimationValue;
+ valueKind: AnimationValueKind;
+ numericRange?: NumericRange;
+}): AnimationValue | null {
+ if (valueKind === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ return clampNumericRange({ value, numericRange });
+ }
+
+ if (valueKind === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (valueKind === "vector") {
+ return isVectorValue(value) ? value : null;
+ }
+
+ return typeof value === "string" || typeof value === "boolean" ? value : null;
+}
+
+export function upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ valueKind,
+ defaultInterpolation,
+ numericRange,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericRange;
+}): ElementAnimations | undefined {
+ const coercedValue = coerceAnimationValueForPath({
+ value,
+ valueKind,
+ numericRange,
+ });
+ if (coercedValue === null) {
+ return animations;
+ }
+
+ const channel = getChannel({ animations, propertyPath });
+ const targetChannel =
+ channel && channel.valueKind === valueKind
+ ? channel
+ : createEmptyChannelForValueKind({ valueKind });
+ const updatedChannel = upsertKeyframe({
+ channel: targetChannel,
+ time,
+ value: coercedValue,
+ interpolation: interpolation ?? defaultInterpolation,
+ keyframeId,
+ });
+
+ return (
+ setChannel({
+ animations,
+ propertyPath,
+ channel: updatedChannel,
+ }) ?? { channels: {} }
+ );
+}
+
+export function upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+}): ElementAnimations | undefined {
+ const coercedValue = coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+ });
+ if (coercedValue === null) {
+ return animations;
+ }
+
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value: coercedValue,
+ interpolation,
+ keyframeId,
+ valueKind: propertyDefinition.valueKind,
+ defaultInterpolation: getDefaultInterpolationForProperty({
+ propertyPath,
+ }),
+ numericRange: propertyDefinition.numericRange,
+ });
+}
+
+
export function upsertKeyframe({
channel,
time,
@@ -516,62 +680,13 @@ export function splitAnimationsAtTime({
};
}
-export function upsertElementKeyframe({
- animations,
- propertyPath,
- time,
- value,
- interpolation,
- keyframeId,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- time: number;
- value: AnimationValue;
- interpolation?: AnimationInterpolation;
- keyframeId?: string;
-}): ElementAnimations | undefined {
- const coercedValue = coerceAnimationValueForProperty({
- propertyPath,
- value,
- });
- if (coercedValue === null) {
- return animations;
- }
-
- const defaultInterpolation = getDefaultInterpolationForProperty({
- propertyPath,
- });
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- const channel = getChannel({ animations, propertyPath });
- const targetChannel =
- channel && channel.valueKind === propertyDefinition.valueKind
- ? channel
- : createEmptyChannel({ propertyPath });
- const updatedChannel = upsertKeyframe({
- channel: targetChannel,
- time,
- value: coercedValue,
- interpolation: interpolation ?? defaultInterpolation,
- keyframeId,
- });
-
- return (
- setChannel({
- animations,
- propertyPath,
- channel: updatedChannel,
- }) ?? { channels: {} }
- );
-}
-
export function removeElementKeyframe({
animations,
propertyPath,
keyframeId,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}): ElementAnimations | undefined {
const channel = getChannel({ animations, propertyPath });
@@ -593,7 +708,7 @@ export function retimeElementKeyframe({
time,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
time: number;
}): ElementAnimations | undefined {
diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts
index 67166156..859da863 100644
--- a/apps/web/src/lib/animation/number-channel.ts
+++ b/apps/web/src/lib/animation/number-channel.ts
@@ -2,7 +2,7 @@ import type {
AnimationPropertyPath,
ElementAnimations,
NumberAnimationChannel,
-} from "@/types/animation";
+} from "@/lib/animation/types";
export function getNumberChannelForPath({
animations,
diff --git a/apps/web/src/lib/animation/property-groups.ts b/apps/web/src/lib/animation/property-groups.ts
new file mode 100644
index 00000000..61b4fc4a
--- /dev/null
+++ b/apps/web/src/lib/animation/property-groups.ts
@@ -0,0 +1,39 @@
+import type {
+ AnimationPropertyGroup,
+ AnimationPropertyPath,
+ ElementAnimations,
+} from "@/lib/animation/types";
+import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
+import { getKeyframeAtTime } from "./keyframe-query";
+
+export interface GroupKeyframeRef {
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}
+
+export function getGroupKeyframesAtTime({
+ animations,
+ group,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ group: AnimationPropertyGroup;
+ time: number;
+}): GroupKeyframeRef[] {
+ return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => {
+ const keyframe = getKeyframeAtTime({ animations, propertyPath, time });
+ return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : [];
+ });
+}
+
+export function hasGroupKeyframeAtTime({
+ animations,
+ group,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ group: AnimationPropertyGroup;
+ time: number;
+}): boolean {
+ return getGroupKeyframesAtTime({ animations, group, time }).length > 0;
+}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
index 361ae3ba..0b28e783 100644
--- a/apps/web/src/lib/animation/property-registry.ts
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -4,25 +4,35 @@ import type {
AnimationValue,
AnimationValueKind,
DiscreteValue,
-} from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+ VectorValue,
+} from "@/lib/animation/types";
+import { isVectorValue } from "./vector-channel";
+import type { TimelineElement } from "@/lib/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
- DEFAULT_TEXT_BACKGROUND,
} from "@/constants/text-constants";
-import { isVisualElement } from "@/lib/timeline/element-utils";
+import {
+ canElementHaveAudio,
+ isVisualElement,
+} from "@/lib/timeline/element-utils";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { snapToStep } from "@/utils/math";
-interface NumericRange {
+export interface NumericSpec {
min?: number;
max?: number;
+ step?: number;
}
-interface AnimationPropertyDefinition {
+export type NumericRange = NumericSpec;
+
+export interface AnimationPropertyDefinition {
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
- numericRange?: NumericRange;
+ numericRange?: NumericSpec;
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
setValue: ({
@@ -38,58 +48,57 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
- "transform.position.x": {
- valueKind: "number",
+ "transform.position": {
+ valueKind: "vector",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position.x : null,
+ isVisualElement(element) ? element.transform.position : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
- position: { ...element.transform.position, x: value as number },
+ position: value as VectorValue,
},
}
: element,
},
- "transform.position.y": {
+ "transform.scaleX": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position.y : null,
+ isVisualElement(element) ? element.transform.scaleX : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
- transform: {
- ...element.transform,
- position: { ...element.transform.position, y: value as number },
- },
+ transform: { ...element.transform, scaleX: value as number },
}
: element,
},
- "transform.scale": {
+ "transform.scaleY": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: MIN_TRANSFORM_SCALE },
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scale : null,
+ isVisualElement(element) ? element.transform.scaleY : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
- transform: { ...element.transform, scale: value as number },
+ transform: { ...element.transform, scaleY: value as number },
}
: element,
},
"transform.rotate": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { min: -360, max: 360, step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.rotate : null,
@@ -104,7 +113,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
opacity: {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0, max: 1 },
+ numericRange: { min: 0, max: 1, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
@@ -116,12 +125,12 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
volume: {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0, max: 1 },
- supportsElement: ({ element }) => element.type === "audio",
+ numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
+ supportsElement: ({ element }) => canElementHaveAudio(element),
getValue: ({ element }) =>
- element.type === "audio" ? element.volume : null,
+ canElementHaveAudio(element) ? element.volume ?? 0 : null,
setValue: ({ element, value }) =>
- element.type === "audio"
+ canElementHaveAudio(element)
? { ...element, volume: value as number }
: element,
},
@@ -152,11 +161,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.paddingX": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0 },
+ numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX)
+ ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -169,11 +178,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.paddingY": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0 },
+ numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY)
+ ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -186,10 +195,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.offsetX": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX)
+ ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -202,10 +212,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.offsetY": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY)
+ ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -218,7 +229,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.cornerRadius": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX },
+ numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
@@ -304,20 +315,24 @@ export function getDefaultInterpolationForProperty({
return propertyDefinition.defaultInterpolation;
}
-function clampNumericRange({
+function applyNumericSpec({
value,
numericRange,
}: {
value: number;
- numericRange: NumericRange | undefined;
+ numericRange: NumericSpec | undefined;
}): number {
if (!numericRange) {
return value;
}
+ const steppedValue =
+ numericRange.step != null
+ ? snapToStep({ value, step: numericRange.step })
+ : value;
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, value));
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
}
export function coerceAnimationValueForProperty({
@@ -334,7 +349,7 @@ export function coerceAnimationValueForProperty({
return null;
}
- return clampNumericRange({
+ return applyNumericSpec({
value,
numericRange: propertyDefinition.numericRange,
});
@@ -344,6 +359,10 @@ export function coerceAnimationValueForProperty({
return typeof value === "string" ? value : null;
}
+ if (propertyDefinition.valueKind === "vector") {
+ return isVectorValue(value) ? value : null;
+ }
+
if (typeof value === "string" || typeof value === "boolean") {
return value as DiscreteValue;
}
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
index 147be1b3..8709627d 100644
--- a/apps/web/src/lib/animation/resolve.ts
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -1,8 +1,16 @@
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { Transform } from "@/types/timeline";
-import { getColorValueAtTime, getNumberChannelValueAtTime } from "./interpolation";
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { Transform } from "@/lib/rendering";
+import {
+ getColorValueAtTime,
+ getNumberChannelValueAtTime,
+ getVectorChannelValueAtTime,
+} from "./interpolation";
import { getColorChannelForPath } from "./color-channel";
import { getNumberChannelForPath } from "./number-channel";
+import { getVectorChannelForPath } from "./vector-channel";
export function getElementLocalTime({
timelineTime,
@@ -36,31 +44,29 @@ export function resolveTransformAtTime({
}): Transform {
const safeLocalTime = Math.max(0, localTime);
return {
- position: {
- x: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.position.x",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.position.x,
+ position: getVectorChannelValueAtTime({
+ channel: getVectorChannelForPath({
+ animations,
+ propertyPath: "transform.position",
}),
- y: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.position.y",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.position.y,
+ time: safeLocalTime,
+ fallbackValue: baseTransform.position,
+ }),
+ scaleX: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.scaleX",
}),
- },
- scale: getNumberChannelValueAtTime({
+ time: safeLocalTime,
+ fallbackValue: baseTransform.scaleX,
+ }),
+ scaleY: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
- propertyPath: "transform.scale",
+ propertyPath: "transform.scaleY",
}),
time: safeLocalTime,
- fallbackValue: baseTransform.scale,
+ fallbackValue: baseTransform.scaleY,
}),
rotate: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
@@ -127,22 +133,3 @@ export function resolveColorAtTime({
fallbackValue: baseColor,
});
}
-
-export function resolveVolumeAtTime({
- baseVolume,
- animations,
- localTime,
-}: {
- baseVolume: number;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): number {
- return getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "volume",
- }),
- time: Math.max(0, localTime),
- fallbackValue: baseVolume,
- });
-}
diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts
new file mode 100644
index 00000000..e801bc68
--- /dev/null
+++ b/apps/web/src/lib/animation/target-resolver.ts
@@ -0,0 +1,279 @@
+import type {
+ AnimationInterpolation,
+ AnimationPath,
+ AnimationValue,
+ AnimationValueKind,
+} from "@/lib/animation/types";
+import {
+ parseEffectParamPath,
+ isEffectParamPath,
+} from "@/lib/animation/effect-param-channel";
+import {
+ isGraphicParamPath,
+ parseGraphicParamPath,
+} from "@/lib/animation/graphic-param-channel";
+import type { ParamDefinition } from "@/lib/params";
+import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
+import { getGraphicDefinition } from "@/lib/graphics";
+import type { TimelineElement } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import { snapToStep } from "@/utils/math";
+import {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ type NumericSpec,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+
+export interface AnimationPathDescriptor {
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericSpec;
+ getBaseValue(): AnimationValue | null;
+ setBaseValue(value: AnimationValue): TimelineElement;
+}
+
+export function getParamValueKind({
+ param,
+}: {
+ param: ParamDefinition;
+}): AnimationValueKind {
+ if (param.type === "number") {
+ return "number";
+ }
+
+ if (param.type === "color") {
+ return "color";
+ }
+
+ return "discrete";
+}
+
+export function getParamDefaultInterpolation({
+ param,
+}: {
+ param: ParamDefinition;
+}): AnimationInterpolation {
+ return param.type === "number" || param.type === "color" ? "linear" : "hold";
+}
+
+function getParamNumericRange({
+ param,
+}: {
+ param: ParamDefinition;
+}): NumericSpec | undefined {
+ if (param.type !== "number") {
+ return undefined;
+ }
+
+ return {
+ min: param.min,
+ max: param.max,
+ step: param.step,
+ };
+}
+
+function coerceParamValue({
+ param,
+ value,
+}: {
+ param: ParamDefinition;
+ value: AnimationValue;
+}): number | string | boolean | null {
+ if (param.type === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ const steppedValue = snapToStep({ value, step: param.step });
+ const minValue = param.min;
+ const maxValue = param.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+ }
+
+ if (param.type === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (param.type === "boolean") {
+ return typeof value === "boolean" ? value : null;
+ }
+
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ return param.options.some((option) => option.value === value) ? value : null;
+}
+
+function buildGraphicParamDescriptor({
+ element,
+ paramKey,
+}: {
+ element: TimelineElement;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (element.type !== "graphic") {
+ return null;
+ }
+
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return {
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRange: getParamNumericRange({ param }),
+ getBaseValue: () => element.params[param.key] ?? param.default,
+ setBaseValue: (value) => {
+ const coercedValue = coerceParamValue({ param, value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return {
+ ...element,
+ params: {
+ ...element.params,
+ [param.key]: coercedValue,
+ },
+ };
+ },
+ };
+}
+
+function buildEffectParamDescriptor({
+ element,
+ effectId,
+ paramKey,
+}: {
+ element: TimelineElement;
+ effectId: string;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (!isVisualElement(element)) {
+ return null;
+ }
+
+ const effect = element.effects?.find((candidate) => candidate.id === effectId);
+ if (!effect) {
+ return null;
+ }
+
+ registerDefaultEffects();
+ const definition = effectsRegistry.get(effect.type);
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return {
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRange: getParamNumericRange({ param }),
+ getBaseValue: () => effect.params[param.key] ?? param.default,
+ setBaseValue: (value) => {
+ const coercedValue = coerceParamValue({ param, value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return {
+ ...element,
+ effects:
+ element.effects?.map((candidate) =>
+ candidate.id !== effectId
+ ? candidate
+ : {
+ ...candidate,
+ params: {
+ ...candidate.params,
+ [param.key]: coercedValue,
+ },
+ },
+ ) ?? element.effects,
+ };
+ },
+ };
+}
+
+export function isAnimationPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): propertyPath is AnimationPath {
+ return (
+ isAnimationPropertyPath({ propertyPath }) ||
+ isGraphicParamPath({ propertyPath }) ||
+ isEffectParamPath({ propertyPath })
+ );
+}
+
+export function resolveAnimationTarget({
+ element,
+ path,
+}: {
+ element: TimelineElement;
+ path: AnimationPath;
+}): AnimationPathDescriptor | null {
+ if (isAnimationPropertyPath({ propertyPath: path })) {
+ const propertyDefinition = getAnimationPropertyDefinition({
+ propertyPath: path,
+ });
+ if (!propertyDefinition.supportsElement({ element })) {
+ return null;
+ }
+
+ return {
+ valueKind: propertyDefinition.valueKind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ numericRange: propertyDefinition.numericRange,
+ getBaseValue: () =>
+ getElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ }),
+ setBaseValue: (value) => {
+ const coercedValue = coerceAnimationValueForProperty({
+ propertyPath: path,
+ value,
+ });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return withElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ value: coercedValue,
+ });
+ },
+ };
+ }
+
+ const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
+ if (graphicParamTarget) {
+ return buildGraphicParamDescriptor({
+ element,
+ paramKey: graphicParamTarget.paramKey,
+ });
+ }
+
+ const effectParamTarget = parseEffectParamPath({ propertyPath: path });
+ if (effectParamTarget) {
+ return buildEffectParamDescriptor({
+ element,
+ effectId: effectParamTarget.effectId,
+ paramKey: effectParamTarget.paramKey,
+ });
+ }
+
+ return null;
+}
diff --git a/apps/web/src/types/animation.ts b/apps/web/src/lib/animation/types.ts
index 825894c5..913911d0 100644
--- a/apps/web/src/types/animation.ts
+++ b/apps/web/src/lib/animation/types.ts
@@ -1,7 +1,7 @@
export const ANIMATION_PROPERTY_PATHS = [
- "transform.position.x",
- "transform.position.y",
- "transform.scale",
+ "transform.position",
+ "transform.scaleX",
+ "transform.scaleY",
"transform.rotate",
"opacity",
"volume",
@@ -15,10 +15,24 @@ export const ANIMATION_PROPERTY_PATHS = [
] 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 type AnimationValueKind = "number" | "color" | "discrete";
+export const ANIMATION_PROPERTY_GROUPS = {
+ "transform.scale": ["transform.scaleX", "transform.scaleY"],
+} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
+
+export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
+
+export type VectorValue = { x: number; y: number };
+
+export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
export type DiscreteValue = boolean | string;
-export type AnimationValue = number | string | boolean;
+export type AnimationValue = number | string | boolean | VectorValue;
export type ContinuousKeyframeInterpolation = "linear" | "hold";
export type DiscreteKeyframeInterpolation = "hold";
@@ -45,10 +59,14 @@ export interface ColorKeyframe
export interface DiscreteKeyframe
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
+export interface VectorKeyframe
+ extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
+
export type AnimationKeyframe =
| NumberKeyframe
| ColorKeyframe
- | DiscreteKeyframe;
+ | DiscreteKeyframe
+ | VectorKeyframe;
interface BaseAnimationChannel<
TValueKind extends AnimationValueKind,
@@ -67,10 +85,14 @@ export interface ColorAnimationChannel
export interface DiscreteAnimationChannel
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
+export interface VectorAnimationChannel
+ extends BaseAnimationChannel<"vector", VectorKeyframe> {}
+
export type AnimationChannel =
| NumberAnimationChannel
| ColorAnimationChannel
- | DiscreteAnimationChannel;
+ | DiscreteAnimationChannel
+ | VectorAnimationChannel;
export type ElementAnimationChannelMap = Record<
string,
@@ -82,7 +104,7 @@ export interface ElementAnimations {
}
export interface ElementKeyframe {
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
id: string;
time: number;
value: AnimationValue;
@@ -92,6 +114,6 @@ export interface ElementKeyframe {
export interface SelectedKeyframeRef {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}
diff --git a/apps/web/src/lib/animation/vector-channel.ts b/apps/web/src/lib/animation/vector-channel.ts
new file mode 100644
index 00000000..50c62e40
--- /dev/null
+++ b/apps/web/src/lib/animation/vector-channel.ts
@@ -0,0 +1,72 @@
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ VectorAnimationChannel,
+ VectorValue,
+} from "@/lib/animation/types";
+
+export function isVectorValue(value: unknown): value is VectorValue {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "x" in value &&
+ "y" in value &&
+ typeof (value as VectorValue).x === "number" &&
+ typeof (value as VectorValue).y === "number"
+ );
+}
+
+export function getVectorChannelForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): VectorAnimationChannel | undefined {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.valueKind !== "vector") {
+ return undefined;
+ }
+ return channel;
+}
+
+export function getVectorChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: VectorAnimationChannel | undefined;
+ time: number;
+ fallbackValue: VectorValue;
+}): VectorValue {
+ if (!channel || channel.keyframes.length === 0) {
+ return fallbackValue;
+ }
+
+ const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time);
+ const first = keyframes[0];
+ const last = keyframes[keyframes.length - 1];
+
+ if (!first || !last) return fallbackValue;
+ if (time <= first.time) return first.value;
+ if (time >= last.time) return last.value;
+
+ for (let i = 0; i < keyframes.length - 1; i++) {
+ const left = keyframes[i];
+ const right = keyframes[i + 1];
+ if (time < left.time || time > right.time) continue;
+
+ if (left.interpolation === "hold") return left.value;
+
+ const span = right.time - left.time;
+ if (span === 0) return right.value;
+
+ const t = (time - left.time) / span;
+ return {
+ x: left.value.x + (right.value.x - left.value.x) * t,
+ y: left.value.y + (right.value.y - left.value.y) * t,
+ };
+ }
+
+ return last.value;
+}
diff --git a/apps/web/src/lib/blog/query.ts b/apps/web/src/lib/blog/query.ts
index f814eeaf..5f7c7474 100644
--- a/apps/web/src/lib/blog/query.ts
+++ b/apps/web/src/lib/blog/query.ts
@@ -5,7 +5,7 @@ import type {
MarblePost,
MarblePostList,
MarbleTagList,
-} from "@/types/blog";
+} from "@/lib/blog/types";
import { unified } from "unified";
import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify";
diff --git a/apps/web/src/types/blog.ts b/apps/web/src/lib/blog/types.ts
index 16795b02..16795b02 100644
--- a/apps/web/src/types/blog.ts
+++ b/apps/web/src/lib/blog/types.ts
diff --git a/apps/web/src/lib/cancel-interaction.ts b/apps/web/src/lib/cancel-interaction.ts
new file mode 100644
index 00000000..0cb07f4f
--- /dev/null
+++ b/apps/web/src/lib/cancel-interaction.ts
@@ -0,0 +1,24 @@
+type CancelFn = () => void;
+
+const cancellers = new Set<CancelFn>();
+
+export function registerCanceller({ fn }: { fn: CancelFn }): () => void {
+ cancellers.add(fn);
+
+ return () => {
+ cancellers.delete(fn);
+ };
+}
+
+export function cancelInteraction(): boolean {
+ if (cancellers.size === 0) return false;
+
+ const activeCancellers = Array.from(cancellers);
+ cancellers.clear();
+
+ for (const cancel of activeCancellers) {
+ cancel();
+ }
+
+ return true;
+}
diff --git a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
deleted file mode 100644
index d2356a39..00000000
--- a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
+++ /dev/null
@@ -1,433 +0,0 @@
-import { afterEach, describe, expect, test } from "bun:test";
-import { EditorCore } from "@/core";
-import type { TimelineTrack, VideoElement } from "@/types/timeline";
-import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
-import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
-import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
-import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements";
-import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements";
-import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe";
-import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe";
-import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
-
-type MockEditor = {
- timeline: {
- getTracks: () => TimelineTrack[];
- updateTracks: (tracks: TimelineTrack[]) => void;
- };
- selection: {
- getSelectedElements: () => { trackId: string; elementId: string }[];
- setSelectedElements: ({
- elements,
- }: {
- elements: { trackId: string; elementId: string }[];
- }) => void;
- };
-};
-
-const originalGetInstance = EditorCore.getInstance;
-
-function mockEditorCore({ editor }: { editor: MockEditor }): void {
- (
- EditorCore as unknown as {
- getInstance: () => EditorCore;
- }
- ).getInstance = () => editor as unknown as EditorCore;
-}
-
-function restoreEditorCore(): void {
- (
- EditorCore as unknown as {
- getInstance: typeof EditorCore.getInstance;
- }
- ).getInstance = originalGetInstance;
-}
-
-function buildVideoElement(): VideoElement {
- return {
- id: "element-1",
- name: "Clip",
- type: "video",
- mediaId: "media-1",
- duration: 8,
- startTime: 1,
- trimStart: 0,
- trimEnd: 0,
- transform: DEFAULT_TRANSFORM,
- opacity: 1,
- animations: {
- channels: {
- "transform.scale": {
- valueKind: "number",
- keyframes: [
- { id: "kf-a", time: 0, value: 1, interpolation: "linear" },
- { id: "kf-b", time: 3, value: 1.5, interpolation: "linear" },
- { id: "kf-c", time: 6, value: 2, interpolation: "linear" },
- ],
- },
- },
- },
- };
-}
-
-function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] {
- return [
- {
- id: "track-1",
- name: "Main",
- type: "video",
- elements: [element],
- isMain: true,
- muted: false,
- hidden: false,
- },
- ];
-}
-
-afterEach(() => {
- restoreEditorCore();
-});
-
-describe("keyframe-aware timeline commands", () => {
- test("duration updates clamp keyframes beyond the new duration", () => {
- const tracks = buildTracks({ element: buildVideoElement() });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new UpdateElementDurationCommand({
- trackId: "track-1",
- elementId: "element-1",
- duration: 3,
- }).execute();
-
- const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations;
- expect(
- updatedElement?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 3]);
- });
-
- test("trim updates clamp keyframes when duration is changed", () => {
- const tracks = buildTracks({ element: buildVideoElement() });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new UpdateElementTrimCommand({
- elementId: "element-1",
- trimStart: 0,
- trimEnd: 0,
- startTime: 1,
- duration: 2,
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- expect(updatedElement.duration).toBe(2);
- expect(
- updatedElement.animations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0]);
- });
-
- test("split rebases right-side keyframes and keeps continuity at split time", () => {
- const tracks = buildTracks({ element: buildVideoElement() });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new SplitElementsCommand({
- elements: [{ trackId: "track-1", elementId: "element-1" }],
- splitTime: 5,
- }).execute();
-
- const leftElement = updatedTracks[0].elements.find(
- (element) => element.id === "element-1",
- ) as VideoElement;
- const rightElement = updatedTracks[0].elements.find(
- (element) => element.id !== "element-1",
- ) as VideoElement;
-
- expect(
- leftElement.animations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 3, 4]);
- expect(
- rightElement.animations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2]);
- expect(
- rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value,
- ).toBeCloseTo(5 / 3, 4);
- });
-
- test("duplicate creates independent keyframe ids for copied element", () => {
- const tracks = buildTracks({ element: buildVideoElement() });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }],
- setSelectedElements: () => {},
- },
- },
- });
-
- new DuplicateElementsCommand({
- elements: [{ trackId: "track-1", elementId: "element-1" }],
- }).execute();
-
- const originalElement = updatedTracks.find(
- (track) => track.id === "track-1",
- )?.elements[0] as VideoElement;
- const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1");
- const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement;
-
- expect(duplicatedElement).toBeDefined();
- expect(
- duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 3, 6]);
- expect(
- duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
- ).not.toBe(
- originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
- );
- });
-});
-
-describe("generic keyframe commands", () => {
- test("upsert adds or updates keyframe at target time", () => {
- const element = buildVideoElement();
- const tracks = buildTracks({ element });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new UpsertKeyframeCommand({
- trackId: "track-1",
- elementId: "element-1",
- propertyPath: "transform.scale",
- time: 2,
- value: 2.5,
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- const keyframes =
- updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
- const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001);
- expect(atTwo?.value).toBe(2.5);
- });
-
- test("remove deletes keyframe by id", () => {
- const element = buildVideoElement();
- const tracks = buildTracks({ element });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new RemoveKeyframeCommand({
- trackId: "track-1",
- elementId: "element-1",
- propertyPath: "transform.scale",
- keyframeId: "kf-b",
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- const keyframes =
- updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
- expect(keyframes).toHaveLength(2);
- expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined();
- expect(updatedElement.transform.scale).toBe(1);
- });
-
- test("remove persists value to base property when channel becomes empty", () => {
- const element: VideoElement = {
- ...buildVideoElement(),
- transform: {
- ...DEFAULT_TRANSFORM,
- scale: 1,
- },
- animations: {
- channels: {
- "transform.scale": {
- valueKind: "number",
- keyframes: [
- {
- id: "only-scale",
- time: 2,
- value: 1.43,
- interpolation: "linear",
- },
- ],
- },
- },
- },
- };
- const tracks = buildTracks({ element });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new RemoveKeyframeCommand({
- trackId: "track-1",
- elementId: "element-1",
- propertyPath: "transform.scale",
- keyframeId: "only-scale",
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- expect(updatedElement.transform.scale).toBe(1.43);
- expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined();
- });
-
- test("upsert supports non-transform paths like opacity", () => {
- const element = buildVideoElement();
- const tracks = buildTracks({ element });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new UpsertKeyframeCommand({
- trackId: "track-1",
- elementId: "element-1",
- propertyPath: "opacity",
- time: 1,
- value: 0.35,
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- const opacityChannel = updatedElement.animations?.channels.opacity;
- expect(opacityChannel?.valueKind).toBe("number");
- expect(opacityChannel?.keyframes[0]?.value).toBe(0.35);
- });
-
- test("retime moves keyframe to new time", () => {
- const element = buildVideoElement();
- const tracks = buildTracks({ element });
- let updatedTracks: TimelineTrack[] = tracks;
- mockEditorCore({
- editor: {
- timeline: {
- getTracks: () => tracks,
- updateTracks: (nextTracks) => {
- updatedTracks = nextTracks;
- },
- },
- selection: {
- getSelectedElements: () => [],
- setSelectedElements: () => {},
- },
- },
- });
-
- new RetimeKeyframeCommand({
- trackId: "track-1",
- elementId: "element-1",
- propertyPath: "transform.scale",
- keyframeId: "kf-b",
- nextTime: 4,
- }).execute();
-
- const updatedElement = updatedTracks[0].elements[0] as VideoElement;
- const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find(
- (existingKeyframe) => existingKeyframe.id === "kf-b",
- );
- expect(keyframe?.time).toBe(4);
- });
-});
diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts
index a2119fc1..03de07c4 100644
--- a/apps/web/src/lib/commands/index.ts
+++ b/apps/web/src/lib/commands/index.ts
@@ -1,6 +1,5 @@
export { Command } from "./base-command";
export { BatchCommand } from "./batch-command";
-export { PreviewTracker } from "./preview-tracker";
export * from "./timeline";
export * from "./media";
diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts
index ddcca426..a3dec0e6 100644
--- a/apps/web/src/lib/commands/media/add-media-asset.ts
+++ b/apps/web/src/lib/commands/media/add-media-asset.ts
@@ -1,8 +1,10 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { MediaAsset } from "@/types/assets";
+import { toast } from "sonner";
+import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service";
+import { hasMediaId } from "@/lib/timeline/element-utils";
export class AddMediaAssetCommand extends Command {
private assetId: string;
@@ -37,6 +39,36 @@ export class AddMediaAssetCommand extends Command {
})
.catch((error) => {
console.error("Failed to save media item:", error);
+
+ const currentAssets = editor.media.getAssets();
+ editor.media.setAssets({
+ assets: currentAssets.filter((asset) => asset.id !== this.assetId),
+ });
+
+ const currentTracks = editor.timeline.getTracks();
+ const orphanedElements: Array<{ trackId: string; elementId: string }> =
+ [];
+
+ for (const track of currentTracks) {
+ for (const element of track.elements) {
+ if (hasMediaId(element) && element.mediaId === this.assetId) {
+ orphanedElements.push({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ }
+ }
+ }
+
+ if (orphanedElements.length > 0) {
+ editor.timeline.deleteElements({ elements: orphanedElements });
+ }
+
+ if (storageService.isQuotaExceededError({ error })) {
+ toast.error("Not enough browser storage", {
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
});
}
diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts
index ee9ffa2d..f519baef 100644
--- a/apps/web/src/lib/commands/media/remove-media-asset.ts
+++ b/apps/web/src/lib/commands/media/remove-media-asset.ts
@@ -1,10 +1,10 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { MediaAsset } from "@/types/assets";
+import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null;
@@ -33,6 +33,13 @@ export class RemoveMediaAssetCommand extends Command {
return;
}
+ if (this.removedAsset.url) {
+ URL.revokeObjectURL(this.removedAsset.url);
+ }
+ if (this.removedAsset.thumbnailUrl) {
+ URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
+ }
+
videoCache.clearVideo({ mediaId: this.assetId });
editor.media.setAssets({
@@ -63,23 +70,30 @@ export class RemoveMediaAssetCommand extends Command {
undo(): void {
const editor = EditorCore.getInstance();
- if (this.savedAssets) {
- editor.media.setAssets({ assets: this.savedAssets });
- }
+ if (this.savedAssets && this.removedAsset) {
+ const restoredAsset: MediaAsset = {
+ ...this.removedAsset,
+ url: URL.createObjectURL(this.removedAsset.file),
+ };
- if (this.savedTracks) {
- editor.timeline.updateTracks(this.savedTracks);
- }
+ editor.media.setAssets({
+ assets: this.savedAssets.map((a) =>
+ a.id === this.assetId ? restoredAsset : a,
+ ),
+ });
- if (this.removedAsset) {
storageService
.saveMediaAsset({
projectId: this.projectId,
- mediaAsset: this.removedAsset,
+ mediaAsset: restoredAsset,
})
.catch((error) => {
console.error("Failed to restore media item on undo:", error);
});
}
+
+ if (this.savedTracks) {
+ editor.timeline.updateTracks(this.savedTracks);
+ }
}
}
diff --git a/apps/web/src/lib/commands/project/update-project-settings.ts b/apps/web/src/lib/commands/project/update-project-settings.ts
index b7ab75ee..b16431c1 100644
--- a/apps/web/src/lib/commands/project/update-project-settings.ts
+++ b/apps/web/src/lib/commands/project/update-project-settings.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TProject, TProjectSettings } from "@/types/project";
+import type { TProject, TProjectSettings } from "@/lib/project/types";
export class UpdateProjectSettingsCommand extends Command {
private savedSettings: TProjectSettings | null = null;
diff --git a/apps/web/src/lib/commands/scene/create-scene.ts b/apps/web/src/lib/commands/scene/create-scene.ts
index 5fea5d53..28bda2fd 100644
--- a/apps/web/src/lib/commands/scene/create-scene.ts
+++ b/apps/web/src/lib/commands/scene/create-scene.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { buildDefaultScene } from "@/lib/scenes";
export class CreateSceneCommand extends Command {
diff --git a/apps/web/src/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts
index 83066e24..58c790e6 100644
--- a/apps/web/src/lib/commands/scene/delete-scene.ts
+++ b/apps/web/src/lib/commands/scene/delete-scene.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
export class DeleteSceneCommand extends Command {
diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts
index 64382f03..28d7a45c 100644
--- a/apps/web/src/lib/commands/scene/move-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/move-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts
index a3029366..19515b97 100644
--- a/apps/web/src/lib/commands/scene/remove-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/remove-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import {
getFrameTime,
diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts
index dc62369d..f1d65e27 100644
--- a/apps/web/src/lib/commands/scene/rename-scene.ts
+++ b/apps/web/src/lib/commands/scene/rename-scene.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
export class RenameSceneCommand extends Command {
diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts
index ac76d8fc..518a30aa 100644
--- a/apps/web/src/lib/commands/scene/toggle-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/toggle-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts
index 5ce91a36..6aeef42b 100644
--- a/apps/web/src/lib/commands/scene/update-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/update-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { Bookmark, TScene } from "@/types/timeline";
+import type { Bookmark, TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
index 800cbb90..00758eb7 100644
--- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
@@ -4,7 +4,7 @@ import type {
TimelineTrack,
TimelineElement,
ClipboardItem,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
index 7fb46c36..4b439870 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
index 97b475f4..207e2373 100644
--- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
index 5299749d..34ca12ab 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
import { buildDefaultEffectInstance } from "@/lib/effects";
function addEffectToElement({
diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
index b475474f..1b309f13 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
function removeEffectFromElement({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
index 81c66503..d946be18 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
function reorderEffectsOnElement({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
index 70bf955d..25e7c356 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
export function toggleEffectOnElement({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
index d9bf0aad..c3443c22 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
@@ -1,8 +1,8 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { EffectParamValues } from "@/types/effects";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { ParamValues } from "@/lib/params";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
function updateEffectParamsOnElement({
element,
@@ -11,7 +11,7 @@ function updateEffectParamsOnElement({
}: {
element: VisualElement;
effectId: string;
- params: Partial<EffectParamValues>;
+ params: Partial<ParamValues>;
}): VisualElement {
const currentEffects = element.effects ?? [];
const updated = currentEffects.map((effect) => {
@@ -36,7 +36,7 @@ export class UpdateClipEffectParamsCommand extends Command {
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
- private readonly params: Partial<EffectParamValues>;
+ private readonly params: Partial<ParamValues>;
constructor({
trackId,
@@ -47,7 +47,7 @@ export class UpdateClipEffectParamsCommand extends Command {
trackId: string;
elementId: string;
effectId: string;
- params: Partial<EffectParamValues>;
+ params: Partial<ParamValues>;
}) {
super();
this.trackId = trackId;
@@ -65,12 +65,12 @@ export class UpdateClipEffectParamsCommand extends Command {
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
- update: (element) => {
- return updateEffectParamsOnElement({
- element: element as VisualElement,
- effectId: this.effectId,
- params: this.params,
- });
+ update: (element) => {
+ return updateEffectParamsOnElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ params: this.params,
+ });
},
});
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
index 413d1adf..899754fb 100644
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -11,3 +11,5 @@ export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
export * from "./effects";
+export * from "./masks";
+export * from "./retime";
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index 68fc9841..acd72b66 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -6,7 +6,7 @@ import type {
TimelineElement,
TrackType,
ElementType,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import {
requiresMediaId,
@@ -19,8 +19,9 @@ import {
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
-import type { MediaAsset } from "@/types/assets";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { MediaAsset } from "@/lib/media/types";
+import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -168,6 +169,14 @@ export class InsertElementCommand extends Command {
return false;
}
+ if (element.type === "graphic") {
+ registerDefaultGraphics();
+ if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
+ console.error("Graphic element must have a valid definitionId");
+ return false;
+ }
+ }
+
if (element.type === "text" && !element.content) {
console.error("Text element must have content");
return false;
@@ -344,9 +353,6 @@ export class InsertElementCommand extends Command {
}: {
element: { type: ElementType };
}): TrackType {
- if (element.type === "video" || element.type === "image") {
- return "video";
- }
- return element.type;
+ return ELEMENT_TRACK_MAP[element.type];
}
}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
index d4922532..984a5ed7 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
@@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command";
import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
export class RemoveEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
index f12f9829..eb5cf742 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -2,15 +2,13 @@ import { EditorCore } from "@/core";
import {
getChannel,
getChannelValueAtTime,
- getElementBaseValueForProperty,
removeElementKeyframe,
- supportsAnimationProperty,
- withElementBaseValueForProperty,
+ resolveAnimationTarget,
} from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { updateElementInTracks } from "@/lib/timeline";
-import type { AnimationPropertyPath } from "@/types/animation";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { AnimationPath, AnimationValue } from "@/lib/animation/types";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
function sampleValueBeforeRemoval({
element,
@@ -18,9 +16,9 @@ function sampleValueBeforeRemoval({
keyframeId,
}: {
element: TimelineElement;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
-}): number | null {
+}): AnimationValue | null {
const channel = getChannel({
animations: element.animations,
propertyPath,
@@ -32,17 +30,20 @@ function sampleValueBeforeRemoval({
return null;
}
- const baseValue = getElementBaseValueForProperty({ element, propertyPath });
- if (baseValue === null || typeof baseValue !== "number") {
+ const target = resolveAnimationTarget({ element, path: propertyPath });
+ if (!target) {
+ return null;
+ }
+ const baseValue = target.getBaseValue();
+ if (baseValue === null) {
return null;
}
- const sampled = getChannelValueAtTime({
+ return getChannelValueAtTime({
channel,
time: keyframe.time,
fallbackValue: baseValue,
});
- return typeof sampled === "number" ? sampled : null;
}
function removeKeyframeAndPersist({
@@ -51,9 +52,14 @@ function removeKeyframeAndPersist({
keyframeId,
}: {
element: TimelineElement;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}): TimelineElement {
+ const target = resolveAnimationTarget({ element, path: propertyPath });
+ if (!target) {
+ return element;
+ }
+
const valueBefore = sampleValueBeforeRemoval({
element,
propertyPath,
@@ -71,11 +77,7 @@ function removeKeyframeAndPersist({
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
const baseElement = shouldPersistToBase
- ? withElementBaseValueForProperty({
- element,
- propertyPath,
- value: valueBefore,
- })
+ ? target.setBaseValue(valueBefore)
: element;
return { ...baseElement, animations: nextAnimations };
@@ -85,7 +87,7 @@ export class RemoveKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
- private readonly propertyPath: AnimationPropertyPath;
+ private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
constructor({
@@ -96,7 +98,7 @@ export class RemoveKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}) {
super();
@@ -114,11 +116,6 @@ export class RemoveKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
- elementPredicate: (element) =>
- supportsAnimationProperty({
- element,
- propertyPath: this.propertyPath,
- }),
update: (element) =>
removeKeyframeAndPersist({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
index 5328419f..9e912f27 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -1,15 +1,15 @@
import { EditorCore } from "@/core";
-import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
+import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation";
import { Command } from "@/lib/commands/base-command";
import { updateElementInTracks } from "@/lib/timeline";
-import type { AnimationPropertyPath } from "@/types/animation";
-import type { TimelineTrack } from "@/types/timeline";
+import type { AnimationPath } from "@/lib/animation/types";
+import type { TimelineTrack } from "@/lib/timeline";
export class RetimeKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
- private readonly propertyPath: AnimationPropertyPath;
+ private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
private readonly nextTime: number;
@@ -22,7 +22,7 @@ export class RetimeKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
nextTime: number;
}) {
@@ -42,14 +42,12 @@ export class RetimeKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
- elementPredicate: (element) =>
- supportsAnimationProperty({
- element,
- propertyPath: this.propertyPath,
- }),
update: (element) => {
+ if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
+ return element;
+ }
+
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
- if (!Number.isFinite(boundedTime)) return element;
return {
...element,
animations: retimeElementKeyframe({
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
index f5236279..285bd80c 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command";
import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
index 370f6865..28e7feff 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -1,19 +1,19 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
-import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
+import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation";
import { updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import type {
+ AnimationPath,
AnimationInterpolation,
- AnimationPropertyPath,
AnimationValue,
-} from "@/types/animation";
+} from "@/lib/animation/types";
export class UpsertKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
- private readonly propertyPath: AnimationPropertyPath;
+ private readonly propertyPath: AnimationPath;
private readonly time: number;
private readonly value: AnimationValue;
private readonly interpolation: AnimationInterpolation | undefined;
@@ -30,7 +30,7 @@ export class UpsertKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
@@ -54,22 +54,28 @@ export class UpsertKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
- elementPredicate: (element) =>
- supportsAnimationProperty({
- element,
- propertyPath: this.propertyPath,
- }),
update: (element) => {
+ const target = resolveAnimationTarget({
+ element,
+ path: this.propertyPath,
+ });
+ if (!target) {
+ return element;
+ }
+
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
return {
...element,
- animations: upsertElementKeyframe({
+ animations: upsertPathKeyframe({
animations: element.animations,
propertyPath: this.propertyPath,
time: boundedTime,
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
+ valueKind: target.valueKind,
+ defaultInterpolation: target.defaultInterpolation,
+ numericRange: target.numericRange,
}),
};
},
diff --git a/apps/web/src/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts
new file mode 100644
index 00000000..acc47184
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/index.ts
@@ -0,0 +1,2 @@
+export { RemoveMaskCommand } from "./remove-mask";
+export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
diff --git a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts
new file mode 100644
index 00000000..ee09cbdc
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts
@@ -0,0 +1,64 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
+
+function removeMaskFromElement({
+ element,
+ maskId,
+}: {
+ element: MaskableElement;
+ maskId: string;
+}): MaskableElement {
+ const currentMasks = element.masks ?? [];
+ const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId);
+ return { ...element, masks: filteredMasks };
+}
+
+export class RemoveMaskCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly maskId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.maskId = maskId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isMaskableElement,
+ update: (element) =>
+ removeMaskFromElement({
+ element: element as MaskableElement,
+ maskId: this.maskId,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts
new file mode 100644
index 00000000..ac3c8c7c
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts
@@ -0,0 +1,75 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
+import type { Mask } from "@/lib/masks/types";
+import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
+
+export function toggleMaskInvertedOnElement({
+ element,
+ maskId,
+}: {
+ element: MaskableElement;
+ maskId: string;
+}): MaskableElement {
+ const currentMasks = element.masks ?? [];
+ const toggleMask = <TMask extends Mask>(mask: TMask): TMask => ({
+ ...mask,
+ params: {
+ ...mask.params,
+ inverted: !mask.params.inverted,
+ },
+ });
+ const updatedMasks = currentMasks.map((mask) =>
+ mask.id !== maskId ? mask : toggleMask(mask),
+ );
+
+ return { ...element, masks: updatedMasks };
+}
+
+export class ToggleMaskInvertedCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly maskId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.maskId = maskId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isMaskableElement,
+ update: (element) =>
+ toggleMaskInvertedOnElement({
+ element: element as MaskableElement,
+ maskId: this.maskId,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
index 51e18c21..a36e8e93 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -4,7 +4,7 @@ import type {
TimelineTrack,
TimelineElement,
TrackType,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import {
buildEmptyTrack,
isMainTrack,
diff --git a/apps/web/src/lib/commands/timeline/element/retime/index.ts b/apps/web/src/lib/commands/timeline/element/retime/index.ts
new file mode 100644
index 00000000..5daa59bf
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/retime/index.ts
@@ -0,0 +1 @@
+export { UpdateElementRetimeCommand } from "./update-element-retime";
diff --git a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts
new file mode 100644
index 00000000..d1f859e6
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts
@@ -0,0 +1,114 @@
+import { EditorCore } from "@/core";
+import { clampRetimeRate } from "@/constants/retime-constants";
+import { clampAnimationsToDuration } from "@/lib/animation";
+import { Command } from "@/lib/commands/base-command";
+import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime";
+import { isRetimableElement, updateElementInTracks } from "@/lib/timeline";
+import type { RetimeConfig, TimelineTrack } from "@/lib/timeline";
+
+function getSourceDuration({
+ trimStart,
+ trimEnd,
+ duration,
+ sourceDuration,
+ retime,
+}: {
+ trimStart: number;
+ trimEnd: number;
+ duration: number;
+ sourceDuration?: number;
+ retime?: RetimeConfig;
+}): number {
+ if (typeof sourceDuration === "number") {
+ return sourceDuration;
+ }
+
+ return (
+ trimStart +
+ getSourceSpanAtClipTime({
+ clipTime: duration,
+ retime,
+ }) +
+ trimEnd
+ );
+}
+
+export class UpdateElementRetimeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly retime: RetimeConfig | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ retime,
+ }: {
+ trackId: string;
+ elementId: string;
+ retime?: RetimeConfig;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.retime = retime;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isRetimableElement,
+ update: (element) => {
+ if (!isRetimableElement(element)) {
+ return element;
+ }
+
+ const nextRetime = this.retime
+ ? {
+ ...this.retime,
+ rate: clampRetimeRate({ rate: this.retime.rate }),
+ }
+ : undefined;
+ const sourceDuration = getSourceDuration({
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ duration: element.duration,
+ sourceDuration: element.sourceDuration,
+ retime: element.retime,
+ });
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - element.trimStart - element.trimEnd,
+ );
+ const nextDuration = getTimelineDurationForSourceSpan({
+ sourceSpan: visibleSourceSpan,
+ retime: nextRetime,
+ });
+
+ return {
+ ...element,
+ retime: nextRetime,
+ duration: nextDuration,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: nextDuration,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts
index 37ccbb12..2acac683 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -1,9 +1,10 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
-import { rippleShiftElements } from "@/lib/timeline";
+import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { splitAnimationsAtTime } from "@/lib/animation";
+import { getSourceSpanAtClipTime } from "@/lib/retime";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -75,6 +76,18 @@ export class SplitElementsCommand extends Command {
const relativeTime = this.splitTime - element.startTime;
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime;
+ const retimeRef = isRetimableElement(element)
+ ? element.retime
+ : undefined;
+ const leftSourceSpan = getSourceSpanAtClipTime({
+ clipTime: leftVisibleDuration,
+ retime: retimeRef,
+ });
+ const totalSourceSpan = getSourceSpanAtClipTime({
+ clipTime: element.duration,
+ retime: retimeRef,
+ });
+ const rightSourceSpan = totalSourceSpan - leftSourceSpan;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations,
splitTime: relativeTime,
@@ -83,63 +96,67 @@ export class SplitElementsCommand extends Command {
if (this.retainSide === "left") {
return [
- {
- ...element,
- duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightVisibleDuration,
- name: `${element.name} (left)`,
- animations: leftAnimations,
- },
- ];
- }
-
- if (this.retainSide === "right") {
- if (this.rippleEnabled && elementsToSplit.length === 1) {
- leftVisibleDurationForRipple = leftVisibleDuration;
- }
- const newId = generateUUID();
- this.rightSideElements.push({
- trackId: track.id,
- elementId: newId,
- });
- return [
- {
- ...element,
- id: newId,
- startTime: this.splitTime,
- duration: rightVisibleDuration,
- trimStart: element.trimStart + leftVisibleDuration,
- name: `${element.name} (right)`,
- animations: rightAnimations,
- },
- ];
- }
-
- // "both" - split into two pieces
- const secondElementId = generateUUID();
- this.rightSideElements.push({
- trackId: track.id,
- elementId: secondElementId,
- });
-
- return [
{
...element,
duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightVisibleDuration,
+ trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
+ ];
+ }
+
+ if (this.retainSide === "right") {
+ if (this.rippleEnabled && elementsToSplit.length === 1) {
+ leftVisibleDurationForRipple = leftVisibleDuration;
+ }
+ const newId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: newId,
+ });
+ return [
{
...element,
- id: secondElementId,
+ id: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
- trimStart: element.trimStart + leftVisibleDuration,
+ trimStart: element.trimStart + leftSourceSpan,
name: `${element.name} (right)`,
animations: rightAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
];
+ }
+
+ // "both" - split into two pieces
+ const secondElementId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: secondElementId,
+ });
+
+ return [
+ {
+ ...element,
+ duration: leftVisibleDuration,
+ trimEnd: element.trimEnd + rightSourceSpan,
+ name: `${element.name} (left)`,
+ animations: leftAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ {
+ ...element,
+ id: secondElementId,
+ startTime: this.splitTime,
+ duration: rightVisibleDuration,
+ trimStart: element.trimStart + leftSourceSpan,
+ name: `${element.name} (right)`,
+ animations: rightAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ ];
});
if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
index 1fa7421e..f416e7c5 100644
--- a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
+++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
index daeb759a..24d51c15 100644
--- a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
+++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { canElementBeHidden } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
index 3366548d..d88cf9cb 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
index e25bc0e3..75773ec2 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
index 7548638a..b2c77418 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
@@ -1,8 +1,9 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
-import { rippleShiftElements } from "@/lib/timeline";
+import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
+import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -48,7 +49,13 @@ export class UpdateElementTrimCommand extends Command {
if (!targetElement) return track;
const nextDuration = this.duration ?? targetElement.duration;
- const nextStartTime = this.startTime ?? targetElement.startTime;
+ const requestedStartTime = this.startTime ?? targetElement.startTime;
+ const nextStartTime = enforceMainTrackStart({
+ tracks: this.savedState ?? [],
+ targetTrackId: track.id,
+ requestedStartTime,
+ excludeElementId: this.elementId,
+ });
const oldEndTime = targetElement.startTime + targetElement.duration;
const newEndTime = nextStartTime + nextDuration;
@@ -60,6 +67,9 @@ export class UpdateElementTrimCommand extends Command {
trimEnd: this.trimEnd,
startTime: nextStartTime,
duration: nextDuration,
+ ...(isRetimableElement(targetElement)
+ ? { retime: targetElement.retime }
+ : {}),
animations: clampAnimationsToDuration({
animations: targetElement.animations,
duration: nextDuration,
@@ -68,7 +78,9 @@ export class UpdateElementTrimCommand extends Command {
if (this.rippleEnabled && Math.abs(shiftAmount) > 0) {
const shiftedOthers = rippleShiftElements({
- elements: track.elements.filter((element) => element.id !== this.elementId),
+ elements: track.elements.filter(
+ (element) => element.id !== this.elementId,
+ ),
afterTime: oldEndTime,
shiftAmount,
});
@@ -77,7 +89,8 @@ export class UpdateElementTrimCommand extends Command {
elements: track.elements.map((element) =>
element.id === this.elementId
? updatedElement
- : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element)
+ : (shiftedOthers.find((shifted) => shifted.id === element.id) ??
+ element),
),
} as typeof track;
}
@@ -85,7 +98,7 @@ export class UpdateElementTrimCommand extends Command {
return {
...track,
elements: track.elements.map((element) =>
- element.id === this.elementId ? updatedElement : element
+ element.id === this.elementId ? updatedElement : element,
),
} as typeof track;
});
diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts
index 643f8e4b..cf0c5cbc 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { updateElementInTracks } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts
index 95338888..dd4ceb64 100644
--- a/apps/web/src/lib/commands/timeline/track/add-track.ts
+++ b/apps/web/src/lib/commands/timeline/track/add-track.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TrackType, TimelineTrack } from "@/types/timeline";
+import type { TrackType, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts
index a32aacd7..35f29b1c 100644
--- a/apps/web/src/lib/commands/timeline/track/remove-track.ts
+++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/timeline";
export class RemoveTrackCommand extends Command {
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
index 3fe526c7..9acf732a 100644
--- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
+++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { canTracktHaveAudio } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
index e81714ac..4bfd53c0 100644
--- a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
+++ b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { canTrackBeHidden } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
index 2ba059c7..a4370cab 100644
--- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
+++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
export class TracksSnapshotCommand extends Command {
diff --git a/apps/web/src/lib/drag-data.ts b/apps/web/src/lib/drag-data.ts
index 067b0a35..d847db95 100644
--- a/apps/web/src/lib/drag-data.ts
+++ b/apps/web/src/lib/drag-data.ts
@@ -1,4 +1,4 @@
-import type { TimelineDragData } from "@/types/drag";
+import type { TimelineDragData } from "@/lib/timeline/drag";
const MIME_TYPE = "application/x-timeline-drag";
let lastDragData: TimelineDragData | null = null;
diff --git a/apps/web/src/lib/effects/definitions/blur.frag.glsl b/apps/web/src/lib/effects/definitions/blur.frag.glsl
index 2c015725..c9ad2eae 100644
--- a/apps/web/src/lib/effects/definitions/blur.frag.glsl
+++ b/apps/web/src/lib/effects/definitions/blur.frag.glsl
@@ -3,6 +3,7 @@ precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_sigma;
+uniform float u_step;
uniform vec2 u_direction;
varying vec2 v_texCoord;
@@ -13,13 +14,12 @@ void main() {
vec4 color = vec4(0.0);
float totalWeight = 0.0;
- // step=1 texel — scaling step size instead causes discrete ghosting artifacts
for (int i = -30; i <= 30; i++) {
- float fi = float(i);
- float weight = exp(-(fi * fi) / (2.0 * u_sigma * u_sigma));
- color += texture2D(u_texture, v_texCoord + texelSize * u_direction * fi) * weight;
+ float pos = float(i) * u_step;
+ float weight = exp(-(pos * pos) / (2.0 * u_sigma * u_sigma));
+ color += texture2D(u_texture, v_texCoord + texelSize * u_direction * pos) * weight;
totalWeight += weight;
}
gl_FragColor = color / totalWeight;
-}
+} \ No newline at end of file
diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts
index 3819f82c..54934402 100644
--- a/apps/web/src/lib/effects/definitions/blur.ts
+++ b/apps/web/src/lib/effects/definitions/blur.ts
@@ -1,6 +1,72 @@
-import type { EffectDefinition } from "@/types/effects";
+import type { EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types";
import blurFragmentShader from "./blur.frag.glsl";
+const MAX_SINGLE_PASS_SIGMA = 10;
+const MAX_STEP = 4;
+const MAX_EFFECTIVE_SIGMA = MAX_SINGLE_PASS_SIGMA * MAX_STEP;
+const MAX_ITERATIONS = 8;
+
+/**
+ * Builds multi-pass gaussian blur passes for a given sigma.
+ * Shared by the blur effect and background blur — they each
+ * compute their own sigma from their own intensity scale.
+ */
+export function buildGaussianBlurPasses({
+ sigmaX,
+ sigmaY,
+}: {
+ sigmaX: number;
+ sigmaY: number;
+}): ResolvedEffectPass[] {
+ const maxSigma = Math.max(sigmaX, sigmaY);
+ if (maxSigma < 0.001) return [];
+
+ const iterations = Math.min(
+ MAX_ITERATIONS,
+ Math.max(
+ 1,
+ Math.ceil(
+ (maxSigma * maxSigma) /
+ (MAX_EFFECTIVE_SIGMA * MAX_EFFECTIVE_SIGMA),
+ ),
+ ),
+ );
+ const perPassSigmaX = sigmaX / Math.sqrt(iterations);
+ const perPassSigmaY = sigmaY / Math.sqrt(iterations);
+ const stepX = Math.max(1, perPassSigmaX / MAX_SINGLE_PASS_SIGMA);
+ const stepY = Math.max(1, perPassSigmaY / MAX_SINGLE_PASS_SIGMA);
+
+ const passes: ResolvedEffectPass[] = [];
+ for (let i = 0; i < iterations; i++) {
+ passes.push({
+ fragmentShader: blurFragmentShader,
+ uniforms: {
+ u_sigma: perPassSigmaX,
+ u_step: stepX,
+ u_direction: [1, 0],
+ },
+ });
+ passes.push({
+ fragmentShader: blurFragmentShader,
+ uniforms: {
+ u_sigma: perPassSigmaY,
+ u_step: stepY,
+ u_direction: [0, 1],
+ },
+ });
+ }
+ return passes;
+}
+
+function intensityToSigma(intensity: number, resolution: number, reference: number): number {
+ return (intensity / 5) * (resolution / reference);
+}
+
+function parseIntensity(effectParams: Record<string, unknown>): number {
+ const raw = effectParams.intensity;
+ return typeof raw === "number" ? raw : Number.parseFloat(String(raw));
+}
+
export const blurEffectDefinition: EffectDefinition = {
type: "blur",
name: "Blur",
@@ -19,32 +85,29 @@ export const blurEffectDefinition: EffectDefinition = {
renderer: {
type: "webgl",
passes: [
- {
- fragmentShader: blurFragmentShader,
- uniforms: ({ effectParams, width }) => {
- const intensity =
- typeof effectParams.intensity === "number"
- ? effectParams.intensity
- : Number.parseFloat(String(effectParams.intensity));
- return {
- u_sigma: Math.max((intensity / 5) * (width / 1920), 0.001),
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams, width }) => ({
+ u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), width, 1920), 0.001),
+ u_step: 1,
u_direction: [1, 0],
- };
+ }),
},
- },
- {
- fragmentShader: blurFragmentShader,
- uniforms: ({ effectParams, height }) => {
- const intensity =
- typeof effectParams.intensity === "number"
- ? effectParams.intensity
- : Number.parseFloat(String(effectParams.intensity));
- return {
- u_sigma: Math.max((intensity / 5) * (height / 1080), 0.001),
+ {
+ fragmentShader: blurFragmentShader,
+ uniforms: ({ effectParams, height }) => ({
+ u_sigma: Math.max(intensityToSigma(parseIntensity(effectParams), height, 1080), 0.001),
+ u_step: 1,
u_direction: [0, 1],
- };
+ }),
},
- },
],
+ buildPasses: ({ effectParams, width, height }) => {
+ const intensity = parseIntensity(effectParams);
+ return buildGaussianBlurPasses({
+ sigmaX: intensityToSigma(intensity, width, 1920),
+ sigmaY: intensityToSigma(intensity, height, 1080),
+ });
+ },
},
};
diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts
index 1742b1d7..edc8c5e2 100644
--- a/apps/web/src/lib/effects/definitions/index.ts
+++ b/apps/web/src/lib/effects/definitions/index.ts
@@ -1,13 +1,13 @@
-import { hasEffect, registerEffect } from "../registry";
+import { effectsRegistry } from "../registry";
import { blurEffectDefinition } from "./blur";
const defaultEffects = [blurEffectDefinition];
export function registerDefaultEffects(): void {
for (const definition of defaultEffects) {
- if (hasEffect({ effectType: definition.type })) {
+ if (effectsRegistry.has(definition.type)) {
continue;
}
- registerEffect({ definition });
+ effectsRegistry.register(definition.type, definition);
}
}
diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts
index 14d06d84..549811f5 100644
--- a/apps/web/src/lib/effects/index.ts
+++ b/apps/web/src/lib/effects/index.ts
@@ -1,29 +1,42 @@
import { generateUUID } from "@/utils/id";
-import { getEffect } from "./registry";
-import type { Effect, EffectParamValues } from "@/types/effects";
-import type { VisualElement } from "@/types/timeline";
+import { buildDefaultParamValues } from "@/lib/registry";
+import { effectsRegistry } from "./registry";
+import type { ParamValues } from "@/lib/params";
+import type { Effect, EffectDefinition, ResolvedEffectPass } from "@/lib/effects/types";
+import { VISUAL_ELEMENT_TYPES } from "@/lib/timeline";
-export { getEffect, getAllEffects, hasEffect, registerEffect } from "./registry";
+export { effectsRegistry } from "./registry";
export { registerDefaultEffects } from "./definitions";
-export const EFFECT_TARGET_ELEMENT_TYPES: VisualElement["type"][] = [
- "video",
- "image",
- "text",
- "sticker",
-];
+export function resolveEffectPasses({
+ definition,
+ effectParams,
+ width,
+ height,
+}: {
+ definition: EffectDefinition;
+ effectParams: ParamValues;
+ width: number;
+ height: number;
+}): ResolvedEffectPass[] {
+ if (definition.renderer.buildPasses) {
+ return definition.renderer.buildPasses({ effectParams, width, height });
+ }
+ return definition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({ effectParams, width, height }),
+ }));
+}
+
+export const EFFECT_TARGET_ELEMENT_TYPES = VISUAL_ELEMENT_TYPES;
export function buildDefaultEffectInstance({
effectType,
}: {
effectType: string;
}): Effect {
- const definition = getEffect({ effectType });
-
- const params: EffectParamValues = {};
- for (const paramDef of definition.params) {
- params[paramDef.key] = paramDef.default;
- }
+ const definition = effectsRegistry.get(effectType);
+ const params: ParamValues = buildDefaultParamValues(definition.params);
return {
id: generateUUID(),
diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts
index 568b8924..2b85007a 100644
--- a/apps/web/src/lib/effects/registry.ts
+++ b/apps/web/src/lib/effects/registry.ts
@@ -1,31 +1,10 @@
-import type { EffectDefinition } from "@/types/effects";
+import { DefinitionRegistry } from "@/lib/registry";
+import type { EffectDefinition } from "@/lib/effects/types";
-const effectDefinitions = new Map<string, EffectDefinition>();
-
-export function registerEffect({
- definition,
-}: {
- definition: EffectDefinition;
-}): void {
- effectDefinitions.set(definition.type, definition);
-}
-
-export function hasEffect({ effectType }: { effectType: string }): boolean {
- return effectDefinitions.has(effectType);
-}
-
-export function getEffect({
- effectType,
-}: {
- effectType: string;
-}): EffectDefinition {
- const definition = effectDefinitions.get(effectType);
- if (!definition) {
- throw new Error(`Unknown effect type: ${effectType}`);
+export class EffectsRegistry extends DefinitionRegistry<string, EffectDefinition> {
+ constructor() {
+ super("effect");
}
- return definition;
}
-export function getAllEffects(): EffectDefinition[] {
- return Array.from(effectDefinitions.values());
-}
+export const effectsRegistry = new EffectsRegistry();
diff --git a/apps/web/src/lib/effects/types.ts b/apps/web/src/lib/effects/types.ts
new file mode 100644
index 00000000..7f85389f
--- /dev/null
+++ b/apps/web/src/lib/effects/types.ts
@@ -0,0 +1,42 @@
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+
+export interface Effect {
+ id: string;
+ type: string;
+ params: ParamValues;
+ enabled: boolean;
+}
+
+export interface ResolvedEffectPass {
+ fragmentShader: string;
+ uniforms: Record<string, number | number[]>;
+}
+
+export interface WebGLEffectPass {
+ fragmentShader: string;
+ uniforms(params: {
+ effectParams: ParamValues;
+ width: number;
+ height: number;
+ }): Record<string, number | number[]>;
+}
+
+export interface WebGLEffectRenderer {
+ type: "webgl";
+ passes: WebGLEffectPass[];
+ buildPasses?: (params: {
+ effectParams: ParamValues;
+ width: number;
+ height: number;
+ }) => ResolvedEffectPass[];
+}
+
+export type EffectRenderer = WebGLEffectRenderer;
+
+export interface EffectDefinition {
+ type: string;
+ name: string;
+ keywords: string[];
+ params: ParamDefinition[];
+ renderer: EffectRenderer;
+}
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
index ae68144b..68d2bc7f 100644
--- a/apps/web/src/lib/export.ts
+++ b/apps/web/src/lib/export.ts
@@ -1,5 +1,36 @@
import { EXPORT_MIME_TYPES } from "@/constants/export-constants";
-import type { ExportFormat } from "@/types/export";
+
+export const EXPORT_QUALITY_VALUES = [
+ "low",
+ "medium",
+ "high",
+ "very_high",
+] as const;
+
+export const EXPORT_FORMAT_VALUES = ["mp4", "webm"] as const;
+
+export type ExportFormat = (typeof EXPORT_FORMAT_VALUES)[number];
+export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number];
+
+export interface ExportOptions {
+ format: ExportFormat;
+ quality: ExportQuality;
+ fps?: number;
+ includeAudio?: boolean;
+}
+
+export interface ExportResult {
+ success: boolean;
+ buffer?: ArrayBuffer;
+ error?: string;
+ cancelled?: boolean;
+}
+
+export interface ExportState {
+ isExporting: boolean;
+ progress: number;
+ result: ExportResult | null;
+}
export function getExportMimeType({
format,
diff --git a/apps/web/src/lib/fonts/google-fonts.ts b/apps/web/src/lib/fonts/google-fonts.ts
index 7e55dffd..580ce406 100644
--- a/apps/web/src/lib/fonts/google-fonts.ts
+++ b/apps/web/src/lib/fonts/google-fonts.ts
@@ -1,14 +1,16 @@
-import type { FontAtlas } from "@/types/fonts";
+import type { FontAtlas } from "@/lib/fonts/types";
import { SYSTEM_FONTS } from "@/constants/font-constants";
const GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2";
+const FONT_ATLAS_PATH = "/fonts/font-atlas.json";
+const FONT_CHUNK_PATH_PREFIX = "/fonts/font-chunk-";
const fullLoaded = new Set<string>();
let cachedAtlas: FontAtlas | null = null;
let atlasFetchPromise: Promise<FontAtlas | null> | null = null;
-function encodeFamily(family: string): string {
+function encodeGoogleFontsFamily(family: string): string {
return family.replace(/ /g, "+");
}
@@ -19,17 +21,19 @@ export function getCachedFontAtlas(): FontAtlas | null {
export function clearFontAtlasCache(): void {
cachedAtlas = null;
atlasFetchPromise = null;
+ fullLoaded.clear();
}
-async function fetchAtlas(): Promise<FontAtlas | null> {
- if (cachedAtlas) return cachedAtlas;
+export function loadFontAtlas(): Promise<FontAtlas | null> {
+ if (cachedAtlas) return Promise.resolve(cachedAtlas);
if (atlasFetchPromise) return atlasFetchPromise;
- atlasFetchPromise = fetch("/fonts/font-atlas.json")
+ atlasFetchPromise = fetch(FONT_ATLAS_PATH)
.then(async (response) => {
if (!response.ok) return null;
const data: FontAtlas = await response.json();
cachedAtlas = data;
+ preloadChunkImages({ atlas: data });
return data;
})
.catch(() => null);
@@ -42,18 +46,12 @@ function preloadChunkImages({ atlas }: { atlas: FontAtlas }): void {
...Object.values(atlas.fonts).map((entry) => entry.ch),
);
for (let i = 0; i <= maxChunk; i++) {
+ // hint browser to preload chunk images without blocking
const img = new Image();
- img.src = `/fonts/font-chunk-${i}.avif`;
+ img.src = `${FONT_CHUNK_PATH_PREFIX}${i}.avif`;
}
}
-export function prefetchFontAtlas(): Promise<FontAtlas | null> {
- return fetchAtlas().then((atlas) => {
- if (atlas) preloadChunkImages({ atlas });
- return atlas;
- });
-}
-
export async function loadFullFont({
family,
weights = [400, 700],
@@ -63,7 +61,7 @@ export async function loadFullFont({
}): Promise<void> {
if (fullLoaded.has(family)) return;
- const url = `${GOOGLE_FONTS_CSS}?family=${encodeFamily(family)}:wght@${weights.join(";")}&display=swap`;
+ const url = `${GOOGLE_FONTS_CSS}?family=${encodeGoogleFontsFamily(family)}:wght@${weights.join(";")}&display=swap`;
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = url;
diff --git a/apps/web/src/types/fonts.ts b/apps/web/src/lib/fonts/types.ts
index a2db6f91..a2db6f91 100644
--- a/apps/web/src/types/fonts.ts
+++ b/apps/web/src/lib/fonts/types.ts
diff --git a/apps/web/src/lib/graphics/definitions/ellipse.ts b/apps/web/src/lib/graphics/definitions/ellipse.ts
new file mode 100644
index 00000000..3fd76608
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/ellipse.ts
@@ -0,0 +1,73 @@
+import type { ParamDefinition } from "@/lib/params";
+import { applyAlignedStroke } from "../stroke";
+import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
+import type { GraphicDefinition } from "../types";
+
+interface EllipseParams {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+ strokeAlign: GraphicStrokeAlign;
+}
+
+const ELLIPSE_PARAMS: ParamDefinition<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
new file mode 100644
index 00000000..e791f917
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/index.ts
@@ -0,0 +1,29 @@
+import { graphicsRegistry } from "../registry";
+import { ellipseGraphicDefinition } from "./ellipse";
+import { polygonGraphicDefinition } from "./polygon";
+import { rectangleGraphicDefinition } from "./rectangle";
+import { starGraphicDefinition } from "./star";
+
+const defaultGraphicDefinitions = [
+ rectangleGraphicDefinition,
+ ellipseGraphicDefinition,
+ polygonGraphicDefinition,
+ starGraphicDefinition,
+];
+
+export function registerDefaultGraphics(): void {
+ for (const definition of defaultGraphicDefinitions) {
+ if (graphicsRegistry.has(definition.id)) {
+ continue;
+ }
+ graphicsRegistry.register(definition.id, definition);
+ }
+}
+
+export {
+ ellipseGraphicDefinition,
+ polygonGraphicDefinition,
+ rectangleGraphicDefinition,
+ starGraphicDefinition,
+};
+export { STROKE_ALIGN_PARAM } from "./shared";
diff --git a/apps/web/src/lib/graphics/definitions/polygon.ts b/apps/web/src/lib/graphics/definitions/polygon.ts
new file mode 100644
index 00000000..780c974c
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/polygon.ts
@@ -0,0 +1,211 @@
+import type { ParamDefinition } from "@/lib/params";
+import { applyAlignedStroke } from "../stroke";
+import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
+import type { GraphicDefinition } from "../types";
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+interface PolygonParams {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+ strokeAlign: GraphicStrokeAlign;
+ sides: number;
+ cornerRadius: number;
+}
+
+const POLYGON_PARAMS: ParamDefinition<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
new file mode 100644
index 00000000..4e7f78f6
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/rectangle.ts
@@ -0,0 +1,85 @@
+import type { ParamDefinition } from "@/lib/params";
+import { applyAlignedStroke } from "../stroke";
+import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
+import type { GraphicDefinition } from "../types";
+
+interface RectangleParams {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+ strokeAlign: GraphicStrokeAlign;
+ cornerRadius: number;
+}
+
+const RECTANGLE_PARAMS: ParamDefinition<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
new file mode 100644
index 00000000..d2833f51
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/shared.ts
@@ -0,0 +1,16 @@
+import type { ParamDefinition } from "@/lib/params";
+
+export type GraphicStrokeAlign = "inside" | "center" | "outside";
+
+export const STROKE_ALIGN_PARAM: ParamDefinition<"strokeAlign"> = {
+ key: "strokeAlign",
+ label: "Stroke align",
+ type: "select",
+ default: "center",
+ group: "stroke",
+ options: [
+ { value: "inside", label: "Inside" },
+ { value: "center", label: "Center" },
+ { value: "outside", label: "Outside" },
+ ],
+};
diff --git a/apps/web/src/lib/graphics/definitions/star.ts b/apps/web/src/lib/graphics/definitions/star.ts
new file mode 100644
index 00000000..c7f9ef56
--- /dev/null
+++ b/apps/web/src/lib/graphics/definitions/star.ts
@@ -0,0 +1,109 @@
+import type { ParamDefinition } from "@/lib/params";
+import { applyAlignedStroke } from "../stroke";
+import { STROKE_ALIGN_PARAM, type GraphicStrokeAlign } from "./shared";
+import type { GraphicDefinition } from "../types";
+
+interface StarParams {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+ strokeAlign: GraphicStrokeAlign;
+ points: number;
+ depth: number;
+}
+
+const STAR_PARAMS: ParamDefinition<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
new file mode 100644
index 00000000..5286e932
--- /dev/null
+++ b/apps/web/src/lib/graphics/index.ts
@@ -0,0 +1,125 @@
+import { buildDefaultParamValues } from "@/lib/registry";
+import type { ParamValues } from "@/lib/params";
+import { graphicsRegistry } from "./registry";
+import {
+ registerDefaultGraphics,
+ ellipseGraphicDefinition,
+ polygonGraphicDefinition,
+ rectangleGraphicDefinition,
+ starGraphicDefinition,
+} from "./definitions";
+import {
+ DEFAULT_GRAPHIC_SOURCE_SIZE,
+ type GraphicInstance,
+ type GraphicDefinition,
+} from "./types";
+
+const graphicPreviewUrlCache = new Map<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
new file mode 100644
index 00000000..7f2ab9fd
--- /dev/null
+++ b/apps/web/src/lib/graphics/registry.ts
@@ -0,0 +1,10 @@
+import { DefinitionRegistry } from "@/lib/registry";
+import type { GraphicDefinition } from "./types";
+
+export class GraphicsRegistry extends DefinitionRegistry<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
new file mode 100644
index 00000000..98f9f43f
--- /dev/null
+++ b/apps/web/src/lib/graphics/stroke.ts
@@ -0,0 +1,102 @@
+import type { GraphicStrokeAlign } from "./definitions/shared";
+
+type GraphicRenderContext =
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D;
+
+function createTempCanvas({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): OffscreenCanvas | HTMLCanvasElement {
+ try {
+ return new OffscreenCanvas(width, height);
+ } catch {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+ }
+}
+
+function applyStroke({
+ ctx,
+ path,
+ strokeWidth,
+ strokeColor,
+}: {
+ ctx: GraphicRenderContext;
+ path: Path2D;
+ strokeWidth: number;
+ strokeColor: string;
+}) {
+ ctx.strokeStyle = strokeColor;
+ ctx.lineWidth = strokeWidth;
+ ctx.stroke(path);
+}
+
+export function applyAlignedStroke({
+ ctx,
+ path,
+ strokeWidth,
+ strokeAlign,
+ strokeColor,
+}: {
+ ctx: GraphicRenderContext;
+ path: Path2D;
+ strokeWidth: number;
+ strokeAlign: GraphicStrokeAlign;
+ strokeColor: string;
+}): void {
+ if (strokeWidth <= 0) {
+ return;
+ }
+
+ if (strokeAlign === "inside") {
+ ctx.save();
+ ctx.clip(path);
+ applyStroke({
+ ctx,
+ path,
+ strokeWidth: strokeWidth * 2,
+ strokeColor,
+ });
+ ctx.restore();
+ return;
+ }
+
+ if (strokeAlign === "outside") {
+ const strokeCanvas = createTempCanvas({
+ width: ctx.canvas.width,
+ height: ctx.canvas.height,
+ });
+ const strokeCtx = strokeCanvas.getContext("2d") as GraphicRenderContext | null;
+ if (!strokeCtx) {
+ return;
+ }
+
+ applyStroke({
+ ctx: strokeCtx,
+ path,
+ strokeWidth: strokeWidth * 2,
+ strokeColor,
+ });
+
+ // Keep only the outer half of the doubled stroke so alpha fills do not
+ // leave a visible inner stroke behind.
+ strokeCtx.globalCompositeOperation = "destination-out";
+ strokeCtx.fill(path);
+
+ ctx.drawImage(strokeCanvas, 0, 0);
+ return;
+ }
+
+ applyStroke({
+ ctx,
+ path,
+ strokeWidth,
+ strokeColor,
+ });
+}
diff --git a/apps/web/src/lib/graphics/types.ts b/apps/web/src/lib/graphics/types.ts
new file mode 100644
index 00000000..af932d39
--- /dev/null
+++ b/apps/web/src/lib/graphics/types.ts
@@ -0,0 +1,23 @@
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+
+export const DEFAULT_GRAPHIC_SOURCE_SIZE = 512;
+
+export interface GraphicRenderContext {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ params: ParamValues;
+ width: number;
+ height: number;
+}
+
+export interface GraphicDefinition {
+ id: string;
+ name: string;
+ keywords: string[];
+ params: ParamDefinition[];
+ render(context: GraphicRenderContext): void;
+}
+
+export interface GraphicInstance {
+ definitionId: string;
+ params: ParamValues;
+}
diff --git a/apps/web/src/lib/guides/definitions/custom.tsx b/apps/web/src/lib/guides/definitions/custom.tsx
new file mode 100644
index 00000000..bc92cb84
--- /dev/null
+++ b/apps/web/src/lib/guides/definitions/custom.tsx
@@ -0,0 +1,24 @@
+import { PlusSignIcon, RulerIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Button } from "@/components/ui/button";
+import type { GuideDefinition } from "@/lib/guides/types";
+
+function CustomGuideOptions() {
+ return (
+ <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
new file mode 100644
index 00000000..0e7a2004
--- /dev/null
+++ b/apps/web/src/lib/guides/definitions/grid.tsx
@@ -0,0 +1,125 @@
+import {
+ GridTableIcon,
+ LayoutThreeColumnIcon,
+ LayoutThreeRowIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { NumberField } from "@/components/ui/number-field";
+import {
+ GRID_MIN,
+ GRID_MAX,
+ DEFAULT_GRID_CONFIG,
+} from "@/constants/guide-constants";
+import { usePreviewStore } from "@/stores/preview-store";
+import { clampRound } from "@/utils/math";
+import { cn } from "@/utils/ui";
+import type { GuideDefinition } from "@/lib/guides/types";
+
+function GridLines({
+ rows,
+ cols,
+ color,
+}: {
+ rows: number;
+ cols: number;
+ color: string;
+}) {
+ const verticals = Array.from(
+ { length: cols - 1 },
+ (_, i) => ((i + 1) / cols) * 100,
+ );
+ const horizontals = Array.from(
+ { length: rows - 1 },
+ (_, i) => ((i + 1) / rows) * 100,
+ );
+
+ return (
+ <>
+ {verticals.map((pct) => (
+ <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
new file mode 100644
index 00000000..ffeec1c0
--- /dev/null
+++ b/apps/web/src/lib/guides/definitions/platforms.tsx
@@ -0,0 +1,65 @@
+import Image from "next/image";
+import type { GuideDefinition } from "@/lib/guides/types";
+import { TikTokLayout } from "./tiktok-layout";
+
+function PlatformLogo({
+ domain,
+ className = "size-4",
+}: {
+ domain: string;
+ className?: string;
+}) {
+ return (
+ <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
new file mode 100644
index 00000000..794d1379
--- /dev/null
+++ b/apps/web/src/lib/guides/definitions/tiktok-layout.tsx
@@ -0,0 +1,335 @@
+export function TikTokLayout() {
+ return (
+ <svg
+ width="100%"
+ height="100%"
+ viewBox="0 0 1080 1920"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>TikTok Layout</title>
+ <path
+ d="M56.2488 1751.28H51.6355C51.458 1750.3 51.1278 1749.43 50.6447 1748.68C50.1617 1747.93 49.5702 1747.3 48.8704 1746.77C48.1704 1746.25 47.3868 1745.85 46.5194 1745.59C45.6617 1745.32 44.7498 1745.19 43.7838 1745.19C42.0391 1745.19 40.4766 1745.63 39.0965 1746.5C37.7264 1747.38 36.642 1748.67 35.8435 1750.36C35.055 1752.06 34.6605 1754.13 34.6605 1756.57C34.6605 1759.04 35.055 1761.12 35.8435 1762.81C36.642 1764.51 37.7313 1765.79 39.1114 1766.65C40.4914 1767.52 42.044 1767.95 43.7689 1767.95C44.7253 1767.95 45.6322 1767.83 46.4897 1767.57C47.3571 1767.3 48.141 1766.91 48.8408 1766.4C49.5408 1765.89 50.1321 1765.26 50.6151 1764.53C51.108 1763.78 51.4481 1762.92 51.6355 1761.95L56.2488 1761.97C56.0023 1763.46 55.5243 1764.83 54.8146 1766.08C54.1145 1767.32 53.2125 1768.39 52.1086 1769.3C51.0143 1770.2 49.7625 1770.89 48.3528 1771.38C46.9431 1771.88 45.4053 1772.12 43.7395 1772.12C41.1173 1772.12 38.7811 1771.5 36.7307 1770.26C34.6803 1769.01 33.0636 1767.22 31.8808 1764.9C30.7077 1762.57 30.1211 1759.79 30.1211 1756.57C30.1211 1753.34 30.7126 1750.57 31.8954 1748.25C33.0785 1745.92 34.6951 1744.14 36.7455 1742.9C38.796 1741.65 41.1272 1741.02 43.7395 1741.02C45.3462 1741.02 46.8447 1741.25 48.2347 1741.72C49.6342 1742.17 50.8912 1742.84 52.005 1743.73C53.1191 1744.6 54.0406 1745.68 54.7701 1746.95C55.4995 1748.21 55.9924 1749.66 56.2488 1751.28ZM61.3612 1771.71V1749H65.6344V1752.61H65.8709C66.285 1751.39 67.0144 1750.43 68.0596 1749.73C69.1143 1749.02 70.307 1748.66 71.6379 1748.66C71.9137 1748.66 72.239 1748.67 72.6138 1748.69C72.9982 1748.71 73.2987 1748.74 73.5155 1748.77V1753C73.3383 1752.95 73.0229 1752.89 72.5693 1752.83C72.1159 1752.76 71.6623 1752.73 71.209 1752.73C70.1641 1752.73 69.2325 1752.95 68.4143 1753.39C67.606 1753.83 66.9653 1754.43 66.4921 1755.21C66.019 1755.98 65.7825 1756.86 65.7825 1757.84V1771.71H61.3612ZM86.317 1772.17C84.0792 1772.17 82.1521 1771.69 80.5355 1770.73C78.9288 1769.77 77.6867 1768.41 76.8094 1766.67C75.9417 1764.91 75.5082 1762.86 75.5082 1760.5C75.5082 1758.18 75.9417 1756.13 76.8094 1754.36C77.6867 1752.58 78.909 1751.2 80.4764 1750.2C82.0535 1749.2 83.8971 1748.71 86.0066 1748.71C87.288 1748.71 88.5301 1748.92 89.7327 1749.34C90.9353 1749.77 92.0148 1750.43 92.9709 1751.34C93.927 1752.25 94.6812 1753.42 95.2332 1754.87C95.7851 1756.31 96.0612 1758.06 96.0612 1760.12V1761.69H78.007V1758.38H91.7288C91.7288 1757.21 91.4922 1756.18 91.0191 1755.29C90.546 1754.38 89.8805 1753.66 89.023 1753.14C88.1752 1752.62 87.1795 1752.36 86.036 1752.36C84.7941 1752.36 83.7097 1752.67 82.783 1753.28C81.8664 1753.88 81.1565 1754.67 80.6537 1755.64C80.1608 1756.61 79.9146 1757.66 79.9146 1758.79V1761.38C79.9146 1762.89 80.1805 1764.19 80.713 1765.25C81.2551 1766.31 82.0093 1767.13 82.9753 1767.69C83.9413 1768.24 85.07 1768.52 86.3613 1768.52C87.1993 1768.52 87.9633 1768.4 88.6532 1768.16C89.3434 1767.92 89.9396 1767.55 90.4424 1767.07C90.9452 1766.58 91.3295 1765.99 91.5958 1765.28L95.7802 1766.03C95.4453 1767.27 94.8438 1768.34 93.9765 1769.27C93.1187 1770.19 92.0392 1770.9 90.738 1771.41C89.4467 1771.92 87.973 1772.17 86.317 1772.17ZM107.576 1772.21C106.137 1772.21 104.836 1771.95 103.673 1771.41C102.509 1770.87 101.588 1770.09 100.907 1769.06C100.237 1768.04 99.9021 1766.78 99.9021 1765.29C99.9021 1764.01 100.148 1762.96 100.641 1762.13C101.134 1761.3 101.8 1760.65 102.638 1760.16C103.476 1759.68 104.412 1759.32 105.447 1759.07C106.482 1758.82 107.537 1758.64 108.611 1758.51C109.972 1758.35 111.076 1758.22 111.923 1758.12C112.771 1758.02 113.387 1757.84 113.772 1757.61C114.156 1757.37 114.348 1756.99 114.348 1756.45V1756.35C114.348 1755.06 113.984 1754.06 113.254 1753.35C112.535 1752.64 111.46 1752.29 110.031 1752.29C108.542 1752.29 107.369 1752.62 106.511 1753.28C105.664 1753.93 105.077 1754.65 104.752 1755.45L100.597 1754.5C101.09 1753.12 101.809 1752.01 102.756 1751.16C103.712 1750.31 104.811 1749.68 106.053 1749.3C107.295 1748.91 108.601 1748.71 109.972 1748.71C110.879 1748.71 111.84 1748.82 112.855 1749.03C113.88 1749.24 114.836 1749.62 115.724 1750.19C116.621 1750.75 117.355 1751.55 117.927 1752.59C118.498 1753.63 118.784 1754.98 118.784 1756.63V1771.71H114.467V1768.61H114.289C114.003 1769.18 113.574 1769.74 113.003 1770.29C112.431 1770.84 111.697 1771.3 110.8 1771.66C109.903 1772.03 108.828 1772.21 107.576 1772.21ZM108.537 1768.67C109.76 1768.67 110.804 1768.42 111.672 1767.94C112.549 1767.46 113.215 1766.83 113.668 1766.05C114.132 1765.26 114.363 1764.42 114.363 1763.52V1760.59C114.206 1760.75 113.9 1760.9 113.446 1761.04C113.003 1761.16 112.495 1761.28 111.923 1761.38C111.352 1761.46 110.795 1761.55 110.252 1761.63C109.71 1761.7 109.257 1761.76 108.892 1761.8C108.035 1761.91 107.251 1762.1 106.541 1762.35C105.841 1762.61 105.279 1762.98 104.856 1763.46C104.442 1763.93 104.234 1764.57 104.234 1765.35C104.234 1766.45 104.639 1767.27 105.447 1767.84C106.255 1768.39 107.285 1768.67 108.537 1768.67ZM135.275 1749V1752.55H122.869V1749H135.275ZM126.196 1743.56H130.617V1765.04C130.617 1765.9 130.745 1766.54 131.001 1766.98C131.258 1767.4 131.588 1767.69 131.992 1767.85C132.406 1768 132.855 1768.07 133.338 1768.07C133.693 1768.07 134.003 1768.05 134.269 1768C134.536 1767.95 134.742 1767.91 134.891 1767.88L135.689 1771.53C135.433 1771.63 135.068 1771.73 134.595 1771.83C134.121 1771.94 133.53 1772 132.82 1772.01C131.657 1772.02 130.573 1771.82 129.567 1771.38C128.562 1770.95 127.748 1770.28 127.128 1769.37C126.507 1768.47 126.196 1767.33 126.196 1765.96V1743.56ZM149.488 1772.17C147.359 1772.17 145.501 1771.68 143.914 1770.7C142.327 1769.73 141.094 1768.36 140.217 1766.61C139.34 1764.86 138.901 1762.81 138.901 1760.46C138.901 1758.11 139.34 1756.04 140.217 1754.28C141.094 1752.52 142.327 1751.15 143.914 1750.17C145.501 1749.2 147.359 1748.71 149.488 1748.71C151.618 1748.71 153.476 1749.2 155.063 1750.17C156.65 1751.15 157.882 1752.52 158.759 1754.28C159.637 1756.04 160.075 1758.11 160.075 1760.46C160.075 1762.81 159.637 1764.86 158.759 1766.61C157.882 1768.36 156.65 1769.73 155.063 1770.7C153.476 1771.68 151.618 1772.17 149.488 1772.17ZM149.503 1768.46C150.883 1768.46 152.027 1768.09 152.934 1767.36C153.841 1766.63 154.511 1765.66 154.944 1764.45C155.388 1763.24 155.61 1761.9 155.61 1760.44C155.61 1759 155.388 1757.67 154.944 1756.45C154.511 1755.23 153.841 1754.25 152.934 1753.51C152.027 1752.77 150.883 1752.41 149.503 1752.41C148.113 1752.41 146.96 1752.77 146.043 1753.51C145.136 1754.25 144.461 1755.23 144.017 1756.45C143.584 1757.67 143.367 1759 143.367 1760.44C143.367 1761.9 143.584 1763.24 144.017 1764.45C144.461 1765.66 145.136 1766.63 146.043 1767.36C146.96 1768.09 148.113 1768.46 149.503 1768.46ZM165.01 1771.71V1749H169.284V1752.61H169.52C169.934 1751.39 170.664 1750.43 171.709 1749.73C172.763 1749.02 173.956 1748.66 175.287 1748.66C175.563 1748.66 175.888 1748.67 176.263 1748.69C176.647 1748.71 176.948 1748.74 177.165 1748.77V1753C176.987 1752.95 176.672 1752.89 176.218 1752.83C175.765 1752.76 175.311 1752.73 174.858 1752.73C173.813 1752.73 172.882 1752.95 172.063 1753.39C171.255 1753.83 170.614 1754.43 170.141 1755.21C169.668 1755.98 169.432 1756.86 169.432 1757.84V1771.71H165.01Z"
+ fill="white"
+ />
+ <path
+ d="M43.4438 1811.44V1814.4H31.6738V1811.44H43.4438ZM35.1043 1806H38.5939V1827.64C38.5939 1828.63 38.7368 1829.37 39.0228 1829.86C39.3184 1830.34 39.6932 1830.67 40.1465 1830.84C40.6097 1830.99 41.0977 1831.07 41.6104 1831.07C41.9947 1831.07 42.3101 1831.05 42.5566 1831.01C42.803 1830.97 43.0003 1830.92 43.1481 1830.9L43.8578 1834.03C43.6212 1834.12 43.291 1834.21 42.8673 1834.3C42.4434 1834.39 41.906 1834.44 41.2554 1834.44C40.2699 1834.44 39.3036 1834.23 38.3573 1833.81C37.421 1833.38 36.6421 1832.74 36.0212 1831.87C35.4099 1831 35.1043 1829.91 35.1043 1828.59V1806ZM55.1775 1834.62C53.1271 1834.62 51.328 1834.13 49.7804 1833.16C48.2426 1832.18 47.04 1830.82 46.1726 1829.06C45.3149 1827.31 44.8863 1825.26 44.8863 1822.91C44.8863 1820.55 45.3149 1818.48 46.1726 1816.72C47.04 1814.96 48.2426 1813.59 49.7804 1812.61C51.328 1811.64 53.1271 1811.15 55.1775 1811.15C57.2279 1811.15 59.022 1811.64 60.5598 1812.61C62.1075 1813.59 63.31 1814.96 64.1675 1816.72C65.0352 1818.48 65.4687 1820.55 65.4687 1822.91C65.4687 1825.26 65.0352 1827.31 64.1675 1829.06C63.31 1830.82 62.1075 1832.18 60.5598 1833.16C59.022 1834.13 57.2279 1834.62 55.1775 1834.62ZM55.1775 1831.49C56.735 1831.49 58.0165 1831.09 59.022 1830.29C60.0274 1829.49 60.7716 1828.44 61.2547 1827.14C61.7377 1825.84 61.9792 1824.43 61.9792 1822.91C61.9792 1821.39 61.7377 1819.98 61.2547 1818.67C60.7716 1817.36 60.0274 1816.3 59.022 1815.49C58.0165 1814.68 56.735 1814.28 55.1775 1814.28C53.62 1814.28 52.3385 1814.68 51.333 1815.49C50.3277 1816.3 49.5834 1817.36 49.1004 1818.67C48.6173 1819.98 48.3758 1821.39 48.3758 1822.91C48.3758 1824.43 48.6173 1825.84 49.1004 1827.14C49.5834 1828.44 50.3277 1829.49 51.333 1830.29C52.3385 1831.09 53.62 1831.49 55.1775 1831.49ZM71.7869 1803.87V1834.15H68.2973V1803.87H71.7869ZM84.2561 1834.62C82.3636 1834.62 80.6926 1834.14 79.2436 1833.19C77.7945 1832.22 76.6609 1830.86 75.8427 1829.11C75.0245 1827.34 74.6154 1825.26 74.6154 1822.85C74.6154 1820.47 75.0245 1818.4 75.8427 1816.65C76.6609 1814.89 77.7995 1813.54 79.2584 1812.58C80.7173 1811.62 82.4029 1811.15 84.3154 1811.15C85.7941 1811.15 86.9621 1811.39 87.8198 1811.89C88.6872 1812.37 89.3477 1812.92 89.801 1813.54C90.2645 1814.15 90.6242 1814.66 90.8805 1815.05H91.1761V1803.87H94.6657V1834.15H91.2945V1830.66H90.8805C90.6242 1831.07 90.2596 1831.59 89.7862 1832.22C89.3131 1832.85 88.638 1833.4 87.7605 1833.89C86.8832 1834.38 85.7152 1834.62 84.2561 1834.62ZM84.7294 1831.49C86.129 1831.49 87.3121 1831.12 88.2781 1830.39C89.2441 1829.65 89.9785 1828.63 90.4813 1827.33C90.9841 1826.02 91.2355 1824.51 91.2355 1822.79C91.2355 1821.1 90.989 1819.62 90.4961 1818.34C90.0032 1817.06 89.2738 1816.07 88.3078 1815.36C87.3418 1814.64 86.1488 1814.28 84.7294 1814.28C83.2508 1814.28 82.0185 1814.66 81.0327 1815.42C80.0568 1816.17 79.3224 1817.19 78.8295 1818.48C78.3465 1819.76 78.105 1821.2 78.105 1822.79C78.105 1824.41 78.3515 1825.88 78.8444 1827.2C79.3471 1828.51 80.0865 1829.56 81.0624 1830.33C82.0482 1831.1 83.2705 1831.49 84.7294 1831.49ZM108.256 1834.15V1811.44H111.627V1814.99H111.923C112.396 1813.78 113.16 1812.84 114.215 1812.17C115.27 1811.49 116.536 1811.15 118.015 1811.15C119.513 1811.15 120.76 1811.49 121.756 1812.17C122.761 1812.84 123.545 1813.78 124.107 1814.99H124.343C124.925 1813.82 125.797 1812.89 126.961 1812.19C128.124 1811.5 129.519 1811.15 131.145 1811.15C133.176 1811.15 134.837 1811.78 136.128 1813.05C137.419 1814.32 138.065 1816.28 138.065 1818.95V1834.15H134.576V1818.95C134.576 1817.28 134.117 1816.08 133.2 1815.36C132.284 1814.64 131.204 1814.28 129.962 1814.28C128.365 1814.28 127.128 1814.76 126.251 1815.73C125.373 1816.68 124.935 1817.9 124.935 1819.36V1834.15H121.386V1818.6C121.386 1817.31 120.967 1816.27 120.129 1815.48C119.291 1814.68 118.212 1814.28 116.891 1814.28C115.984 1814.28 115.136 1814.52 114.348 1815C113.569 1815.49 112.938 1816.16 112.455 1817.02C111.982 1817.86 111.745 1818.84 111.745 1819.96V1834.15H108.256ZM143.382 1842.66C142.79 1842.66 142.263 1842.61 141.799 1842.51C141.336 1842.43 141.016 1842.34 140.838 1842.25L141.725 1839.17C142.573 1839.39 143.322 1839.47 143.973 1839.41C144.624 1839.35 145.2 1839.06 145.703 1838.54C146.216 1838.03 146.684 1837.19 147.108 1836.04L147.758 1834.26L139.36 1811.44H143.145L149.414 1829.53H149.651L155.92 1811.44H159.706L150.065 1837.46C149.631 1838.63 149.094 1839.6 148.453 1840.37C147.812 1841.15 147.068 1841.73 146.221 1842.1C145.383 1842.47 144.436 1842.66 143.382 1842.66ZM177.901 1816.53L174.766 1817.41C174.569 1816.89 174.278 1816.38 173.894 1815.89C173.519 1815.39 173.007 1814.98 172.356 1814.65C171.705 1814.33 170.873 1814.16 169.857 1814.16C168.467 1814.16 167.309 1814.48 166.382 1815.12C165.466 1815.75 165.007 1816.56 165.007 1817.53C165.007 1818.4 165.323 1819.08 165.954 1819.59C166.584 1820.09 167.57 1820.51 168.911 1820.84L172.282 1821.67C174.313 1822.16 175.826 1822.92 176.821 1823.93C177.817 1824.94 178.315 1826.23 178.315 1827.82C178.315 1829.12 177.941 1830.28 177.191 1831.31C176.452 1832.33 175.417 1833.14 174.086 1833.73C172.755 1834.32 171.208 1834.62 169.443 1834.62C167.127 1834.62 165.209 1834.12 163.691 1833.11C162.173 1832.11 161.212 1830.64 160.808 1828.71L164.12 1827.88C164.436 1829.1 165.032 1830.02 165.909 1830.63C166.796 1831.24 167.955 1831.55 169.384 1831.55C171.011 1831.55 172.302 1831.2 173.258 1830.51C174.224 1829.81 174.707 1828.97 174.707 1828C174.707 1827.21 174.431 1826.55 173.879 1826.02C173.327 1825.48 172.479 1825.07 171.336 1824.8L167.55 1823.92C165.471 1823.43 163.943 1822.66 162.967 1821.63C162.001 1820.58 161.518 1819.28 161.518 1817.71C161.518 1816.43 161.877 1815.3 162.597 1814.31C163.326 1813.32 164.317 1812.55 165.569 1811.99C166.831 1811.43 168.26 1811.15 169.857 1811.15C172.105 1811.15 173.869 1811.64 175.151 1812.62C176.442 1813.61 177.359 1814.91 177.901 1816.53ZM190.592 1834.62C188.404 1834.62 186.516 1834.14 184.929 1833.17C183.352 1832.2 182.134 1830.84 181.277 1829.09C180.429 1827.34 180.005 1825.3 180.005 1822.97C180.005 1820.65 180.429 1818.6 181.277 1816.82C182.134 1815.04 183.327 1813.65 184.855 1812.66C186.393 1811.65 188.187 1811.15 190.237 1811.15C191.42 1811.15 192.588 1811.34 193.742 1811.74C194.895 1812.13 195.945 1812.77 196.891 1813.66C197.838 1814.54 198.591 1815.7 199.153 1817.15C199.715 1818.6 199.996 1820.38 199.996 1822.5V1823.98H182.489V1820.96H196.448C196.448 1819.68 196.191 1818.54 195.679 1817.53C195.176 1816.53 194.456 1815.73 193.52 1815.15C192.593 1814.57 191.499 1814.28 190.237 1814.28C188.847 1814.28 187.645 1814.62 186.629 1815.31C185.624 1816 184.85 1816.88 184.308 1817.98C183.766 1819.07 183.495 1820.24 183.495 1821.49V1823.5C183.495 1825.22 183.79 1826.67 184.382 1827.86C184.983 1829.05 185.816 1829.95 186.881 1830.57C187.945 1831.18 189.182 1831.49 190.592 1831.49C191.509 1831.49 192.337 1831.36 193.076 1831.1C193.825 1830.84 194.471 1830.44 195.013 1829.92C195.555 1829.39 195.974 1828.73 196.27 1827.94L199.641 1828.88C199.287 1830.03 198.69 1831.03 197.852 1831.9C197.014 1832.76 195.979 1833.43 194.747 1833.91C193.515 1834.38 192.13 1834.62 190.592 1834.62ZM206.296 1803.87V1834.15H202.806V1803.87H206.296ZM220.303 1811.44V1814.4H208.06V1811.44H220.303ZM211.727 1834.15V1808.31C211.727 1807.01 212.032 1805.92 212.643 1805.06C213.255 1804.19 214.048 1803.54 215.024 1803.1C216 1802.67 217.03 1802.45 218.114 1802.45C218.972 1802.45 219.672 1802.52 220.214 1802.66C220.756 1802.8 221.161 1802.93 221.427 1803.04L220.421 1806.06C220.244 1806 219.997 1805.93 219.682 1805.84C219.376 1805.75 218.972 1805.71 218.469 1805.71C217.316 1805.71 216.483 1806 215.97 1806.58C215.468 1807.16 215.216 1808.01 215.216 1809.14V1834.15H211.727ZM241.134 1834.62C239.084 1834.62 237.285 1834.13 235.737 1833.16C234.2 1832.18 232.997 1830.82 232.129 1829.06C231.272 1827.31 230.843 1825.26 230.843 1822.91C230.843 1820.55 231.272 1818.48 232.129 1816.72C232.997 1814.96 234.2 1813.59 235.737 1812.61C237.285 1811.64 239.084 1811.15 241.134 1811.15C243.185 1811.15 244.979 1811.64 246.517 1812.61C248.064 1813.59 249.267 1814.96 250.125 1816.72C250.992 1818.48 251.426 1820.55 251.426 1822.91C251.426 1825.26 250.992 1827.31 250.125 1829.06C249.267 1830.82 248.064 1832.18 246.517 1833.16C244.979 1834.13 243.185 1834.62 241.134 1834.62ZM241.134 1831.49C242.692 1831.49 243.973 1831.09 244.979 1830.29C245.984 1829.49 246.729 1828.44 247.212 1827.14C247.695 1825.84 247.936 1824.43 247.936 1822.91C247.936 1821.39 247.695 1819.98 247.212 1818.67C246.729 1817.36 245.984 1816.3 244.979 1815.49C243.973 1814.68 242.692 1814.28 241.134 1814.28C239.577 1814.28 238.295 1814.68 237.29 1815.49C236.285 1816.3 235.54 1817.36 235.057 1818.67C234.574 1819.98 234.333 1821.39 234.333 1822.91C234.333 1824.43 234.574 1825.84 235.057 1827.14C235.54 1828.44 236.285 1829.49 237.29 1830.29C238.295 1831.09 239.577 1831.49 241.134 1831.49ZM257.744 1820.49V1834.15H254.254V1811.44H257.626V1814.99H257.921C258.454 1813.84 259.262 1812.91 260.345 1812.21C261.43 1811.5 262.83 1811.15 264.545 1811.15C266.083 1811.15 267.429 1811.46 268.582 1812.09C269.735 1812.71 270.632 1813.66 271.273 1814.93C271.915 1816.19 272.236 1817.79 272.236 1819.72V1834.15H268.746V1819.96C268.746 1818.17 268.282 1816.78 267.356 1815.79C266.43 1814.78 265.157 1814.28 263.541 1814.28C262.427 1814.28 261.43 1814.52 260.553 1815C259.686 1815.49 259.001 1816.19 258.498 1817.12C257.995 1818.05 257.744 1819.17 257.744 1820.49ZM285.635 1834.62C283.447 1834.62 281.56 1834.14 279.972 1833.17C278.395 1832.2 277.177 1830.84 276.319 1829.09C275.473 1827.34 275.049 1825.3 275.049 1822.97C275.049 1820.65 275.473 1818.6 276.319 1816.82C277.177 1815.04 278.369 1813.65 279.897 1812.66C281.435 1811.65 283.231 1811.15 285.281 1811.15C286.463 1811.15 287.631 1811.34 288.784 1811.74C289.937 1812.13 290.988 1812.77 291.933 1813.66C292.88 1814.54 293.635 1815.7 294.197 1817.15C294.759 1818.6 295.04 1820.38 295.04 1822.5V1823.98H277.531V1820.96H291.491C291.491 1819.68 291.236 1818.54 290.723 1817.53C290.218 1816.53 289.5 1815.73 288.563 1815.15C287.637 1814.57 286.541 1814.28 285.281 1814.28C283.892 1814.28 282.687 1814.62 281.672 1815.31C280.667 1816 279.894 1816.88 279.351 1817.98C278.809 1819.07 278.539 1820.24 278.539 1821.49V1823.5C278.539 1825.22 278.833 1826.67 279.426 1827.86C280.027 1829.05 280.86 1829.95 281.924 1830.57C282.989 1831.18 284.225 1831.49 285.635 1831.49C286.551 1831.49 287.379 1831.36 288.121 1831.1C288.868 1830.84 289.516 1830.44 290.057 1829.92C290.598 1829.39 291.017 1828.73 291.314 1827.94L294.684 1828.88C294.33 1830.03 293.734 1831.03 292.896 1831.9C292.058 1832.76 291.022 1833.43 289.791 1833.91C288.558 1834.38 287.173 1834.62 285.635 1834.62ZM316.586 1834.62C314.397 1834.62 312.508 1834.14 310.92 1833.17C309.343 1832.2 308.128 1830.84 307.269 1829.09C306.421 1827.34 305.997 1825.3 305.997 1822.97C305.997 1820.65 306.421 1818.6 307.269 1816.82C308.128 1815.04 309.32 1813.65 310.847 1812.66C312.386 1811.65 314.179 1811.15 316.229 1811.15C317.413 1811.15 318.582 1811.34 319.735 1811.74C320.888 1812.13 321.936 1812.77 322.884 1813.66C323.831 1814.54 324.583 1815.7 325.145 1817.15C325.707 1818.6 325.988 1820.38 325.988 1822.5V1823.98H308.482V1820.96H322.441C322.441 1819.68 322.184 1818.54 321.671 1817.53C321.169 1816.53 320.448 1815.73 319.514 1815.15C318.584 1814.57 317.491 1814.28 316.229 1814.28C314.84 1814.28 313.637 1814.62 312.622 1815.31C311.615 1816 310.842 1816.88 310.301 1817.98C309.757 1819.07 309.486 1820.24 309.486 1821.49V1823.5C309.486 1825.22 309.783 1826.67 310.374 1827.86C310.975 1829.05 311.808 1829.95 312.872 1830.57C313.937 1831.18 315.175 1831.49 316.586 1831.49C317.502 1831.49 318.329 1831.36 319.069 1831.1C319.818 1830.84 320.463 1830.44 321.005 1829.92C321.549 1829.39 321.968 1828.73 322.262 1827.94L325.634 1828.88C325.278 1830.03 324.682 1831.03 323.844 1831.9C323.006 1832.76 321.973 1833.43 320.739 1833.91C319.508 1834.38 318.121 1834.62 316.586 1834.62ZM328.799 1842.66V1811.44H332.169V1815.05H332.583C332.84 1814.66 333.194 1814.15 333.65 1813.54C334.113 1812.92 334.771 1812.37 335.63 1811.89C336.497 1811.39 337.671 1811.15 339.149 1811.15C341.061 1811.15 342.748 1811.62 344.205 1812.58C345.665 1813.54 346.802 1814.89 347.622 1816.65C348.439 1818.4 348.848 1820.47 348.848 1822.85C348.848 1825.26 348.439 1827.34 347.622 1829.11C346.802 1830.86 345.67 1832.22 344.221 1833.19C342.771 1834.14 341.1 1834.62 339.209 1834.62C337.749 1834.62 336.58 1834.38 335.703 1833.89C334.826 1833.4 334.152 1832.85 333.678 1832.22C333.205 1831.59 332.84 1831.07 332.583 1830.66H332.289V1842.66H328.799ZM332.229 1822.79C332.229 1824.51 332.481 1826.02 332.984 1827.33C333.486 1828.63 334.22 1829.65 335.185 1830.39C336.153 1831.12 337.335 1831.49 338.735 1831.49C340.195 1831.49 341.413 1831.1 342.389 1830.33C343.372 1829.56 344.112 1828.51 344.606 1827.2C345.108 1825.88 345.361 1824.41 345.361 1822.79C345.361 1821.2 345.113 1819.76 344.619 1818.48C344.138 1817.19 343.404 1816.17 342.417 1815.42C341.441 1814.66 340.213 1814.28 338.735 1814.28C337.317 1814.28 336.122 1814.64 335.157 1815.36C334.191 1816.07 333.462 1817.06 332.968 1818.34C332.476 1819.62 332.229 1821.1 332.229 1822.79ZM351.674 1834.15V1811.44H355.164V1834.15H351.674ZM353.449 1807.66C352.767 1807.66 352.182 1807.43 351.69 1806.96C351.206 1806.5 350.964 1805.94 350.964 1805.29C350.964 1804.64 351.206 1804.09 351.69 1803.62C352.182 1803.16 352.767 1802.93 353.449 1802.93C354.128 1802.93 354.711 1803.16 355.193 1803.62C355.687 1804.09 355.932 1804.64 355.932 1805.29C355.932 1805.94 355.687 1806.5 355.193 1806.96C354.711 1807.43 354.128 1807.66 353.449 1807.66ZM375.145 1816.53L372.009 1817.41C371.812 1816.89 371.523 1816.38 371.138 1815.89C370.763 1815.39 370.25 1814.98 369.599 1814.65C368.949 1814.33 368.116 1814.16 367.101 1814.16C365.711 1814.16 364.553 1814.48 363.624 1815.12C362.708 1815.75 362.25 1816.56 362.25 1817.53C362.25 1818.4 362.565 1819.08 363.198 1819.59C363.827 1820.09 364.814 1820.51 366.154 1820.84L369.527 1821.67C371.557 1822.16 373.069 1822.92 374.065 1823.93C375.059 1824.94 375.559 1826.23 375.559 1827.82C375.559 1829.12 375.184 1830.28 374.435 1831.31C373.696 1832.33 372.66 1833.14 371.33 1833.73C369.998 1834.32 368.452 1834.62 366.687 1834.62C364.369 1834.62 362.453 1834.12 360.933 1833.11C359.416 1832.11 358.456 1830.64 358.05 1828.71L361.363 1827.88C361.678 1829.1 362.276 1830.02 363.153 1830.63C364.041 1831.24 365.199 1831.55 366.628 1831.55C368.254 1831.55 369.545 1831.2 370.503 1830.51C371.468 1829.81 371.949 1828.97 371.949 1828C371.949 1827.21 371.674 1826.55 371.122 1826.02C370.57 1825.48 369.722 1825.07 368.579 1824.8L364.793 1823.92C362.714 1823.43 361.186 1822.66 360.21 1821.63C359.245 1820.58 358.76 1819.28 358.76 1817.71C358.76 1816.43 359.12 1815.3 359.84 1814.31C360.569 1813.32 361.561 1812.55 362.812 1811.99C364.075 1811.43 365.503 1811.15 367.101 1811.15C369.347 1811.15 371.111 1811.64 372.394 1812.62C373.685 1813.61 374.601 1814.91 375.145 1816.53ZM387.541 1834.62C385.49 1834.62 383.689 1834.13 382.143 1833.16C380.605 1832.18 379.403 1830.82 378.534 1829.06C377.677 1827.31 377.248 1825.26 377.248 1822.91C377.248 1820.55 377.677 1818.48 378.534 1816.72C379.403 1814.96 380.605 1813.59 382.143 1812.61C383.689 1811.64 385.49 1811.15 387.541 1811.15C389.591 1811.15 391.384 1811.64 392.922 1812.61C394.471 1813.59 395.673 1814.96 396.529 1816.72C397.398 1818.48 397.83 1820.55 397.83 1822.91C397.83 1825.26 397.398 1827.31 396.529 1829.06C395.673 1830.82 394.471 1832.18 392.922 1833.16C391.384 1834.13 389.591 1834.62 387.541 1834.62ZM387.541 1831.49C389.097 1831.49 390.38 1831.09 391.384 1830.29C392.389 1829.49 393.133 1828.44 393.617 1827.14C394.099 1825.84 394.341 1824.43 394.341 1822.91C394.341 1821.39 394.099 1819.98 393.617 1818.67C393.133 1817.36 392.389 1816.3 391.384 1815.49C390.38 1814.68 389.097 1814.28 387.541 1814.28C385.982 1814.28 384.701 1814.68 383.694 1815.49C382.69 1816.3 381.945 1817.36 381.461 1818.67C380.98 1819.98 380.738 1821.39 380.738 1822.91C380.738 1824.43 380.98 1825.84 381.461 1827.14C381.945 1828.44 382.69 1829.49 383.694 1830.29C384.701 1831.09 385.982 1831.49 387.541 1831.49ZM409.234 1834.62C407.342 1834.62 405.672 1834.14 404.222 1833.19C402.775 1832.22 401.64 1830.86 400.823 1829.11C400.003 1827.34 399.595 1825.26 399.595 1822.85C399.595 1820.47 400.003 1818.4 400.823 1816.65C401.64 1814.89 402.778 1813.54 404.238 1812.58C405.698 1811.62 407.381 1811.15 409.294 1811.15C410.772 1811.15 411.941 1811.39 412.8 1811.89C413.666 1812.37 414.327 1812.92 414.78 1813.54C415.243 1814.15 415.602 1814.66 415.86 1815.05H416.157V1803.87H419.644V1834.15H416.274V1830.66H415.86C415.602 1831.07 415.238 1831.59 414.764 1832.22C414.293 1832.85 413.617 1833.4 412.74 1833.89C411.863 1834.38 410.694 1834.62 409.234 1834.62ZM409.708 1831.49C411.108 1831.49 412.292 1831.12 413.258 1830.39C414.223 1829.65 414.957 1828.63 415.462 1827.33C415.964 1826.02 416.214 1824.51 416.214 1822.79C416.214 1821.1 415.969 1819.62 415.475 1818.34C414.983 1817.06 414.254 1816.07 413.286 1815.36C412.321 1814.64 411.129 1814.28 409.708 1814.28C408.23 1814.28 406.999 1814.66 406.012 1815.42C405.037 1816.17 404.303 1817.19 403.808 1818.48C403.327 1819.76 403.085 1821.2 403.085 1822.79C403.085 1824.41 403.332 1825.88 403.824 1827.2C404.326 1828.51 405.065 1829.56 406.041 1830.33C407.027 1831.1 408.251 1831.49 409.708 1831.49ZM433.546 1834.62C431.357 1834.62 429.468 1834.14 427.883 1833.17C426.303 1832.2 425.088 1830.84 424.229 1829.09C423.381 1827.34 422.957 1825.3 422.957 1822.97C422.957 1820.65 423.381 1818.6 424.229 1816.82C425.088 1815.04 426.28 1813.65 427.808 1812.66C429.346 1811.65 431.139 1811.15 433.189 1811.15C434.374 1811.15 435.542 1811.34 436.695 1811.74C437.848 1812.13 438.897 1812.77 439.844 1813.66C440.791 1814.54 441.546 1815.7 442.105 1817.15C442.667 1818.6 442.948 1820.38 442.948 1822.5V1823.98H425.442V1820.96H439.401C439.401 1819.68 439.144 1818.54 438.631 1817.53C438.129 1816.53 437.408 1815.73 436.474 1815.15C435.547 1814.57 434.452 1814.28 433.189 1814.28C431.8 1814.28 430.597 1814.62 429.582 1815.31C428.578 1816 427.802 1816.88 427.261 1817.98C426.72 1819.07 426.447 1820.24 426.447 1821.49V1823.5C426.447 1825.22 426.743 1826.67 427.334 1827.86C427.935 1829.05 428.768 1829.95 429.835 1830.57C430.899 1831.18 432.135 1831.49 433.546 1831.49C434.462 1831.49 435.29 1831.36 436.029 1831.1C436.778 1830.84 437.424 1830.44 437.967 1829.92C438.509 1829.39 438.928 1828.73 439.222 1827.94L442.595 1828.88C442.241 1830.03 441.642 1831.03 440.804 1831.9C439.966 1832.76 438.933 1833.43 437.699 1833.91C436.468 1834.38 435.084 1834.62 433.546 1834.62Z"
+ fill="#E6E6E6"
+ />
+ <path
+ d="M42.9097 1883.58L47.2568 1857.09H50.362L46.0149 1883.58H42.9097ZM30.916 1876.49L31.4206 1873.39H51.5005L50.9959 1876.49H30.916ZM33.5942 1883.58L37.9415 1857.09H41.0467L36.6993 1883.58H33.5942ZM32.4556 1867.28L32.9602 1864.18H53.0401L52.5358 1867.28H32.4556ZM66.1656 1883.94C64.3112 1883.94 62.6724 1883.62 61.2492 1882.98C59.8348 1882.35 58.7264 1881.47 57.9241 1880.35C57.122 1879.23 56.7251 1877.95 56.7337 1876.52C56.7251 1875.4 56.9539 1874.37 57.4195 1873.43C57.8939 1872.48 58.5364 1871.69 59.3473 1871.06C60.1582 1870.42 61.0639 1870.02 62.0642 1869.84V1869.69C60.7446 1869.37 59.688 1868.66 58.8945 1867.57C58.101 1866.47 57.7086 1865.21 57.7172 1863.79C57.7086 1862.44 58.0664 1861.23 58.7909 1860.17C59.524 1859.1 60.5291 1858.26 61.8056 1857.65C63.082 1857.04 64.5355 1856.73 66.1656 1856.73C67.7786 1856.73 69.219 1857.04 70.4869 1857.66C71.7637 1858.27 72.7685 1859.11 73.5016 1860.18C74.2347 1861.24 74.6058 1862.44 74.6144 1863.79C74.6058 1865.21 74.2003 1866.47 73.398 1867.57C72.5959 1868.66 71.5524 1869.37 70.267 1869.69V1869.84C71.2591 1870.02 72.1517 1870.42 72.9452 1871.06C73.7475 1871.69 74.3856 1872.48 74.86 1873.43C75.343 1874.37 75.589 1875.4 75.5975 1876.52C75.589 1877.95 75.1835 1879.23 74.3814 1880.35C73.5791 1881.47 72.4666 1882.35 71.0433 1882.98C69.6289 1883.62 68.0029 1883.94 66.1656 1883.94ZM66.1656 1880.67C67.2613 1880.67 68.2098 1880.49 69.0122 1880.13C69.8142 1879.76 70.4354 1879.24 70.8752 1878.59C71.315 1877.92 71.5394 1877.15 71.5479 1876.26C71.5394 1875.34 71.2978 1874.52 70.8234 1873.81C70.3576 1873.11 69.7237 1872.55 68.9216 1872.15C68.1193 1871.74 67.2009 1871.54 66.1656 1871.54C65.1221 1871.54 64.1948 1871.74 63.3839 1872.15C62.5733 1872.55 61.9349 1873.11 61.4691 1873.81C61.0035 1874.52 60.7747 1875.34 60.7833 1876.26C60.7747 1877.15 60.9861 1877.92 61.4175 1878.59C61.8574 1879.24 62.4827 1879.76 63.2934 1880.13C64.1043 1880.49 65.0617 1880.67 66.1656 1880.67ZM66.1656 1868.34C67.0455 1868.34 67.826 1868.17 68.5075 1867.81C69.1889 1867.46 69.7237 1866.97 70.1119 1866.34C70.5085 1865.71 70.7113 1864.97 70.7199 1864.13C70.7113 1863.3 70.513 1862.57 70.1247 1861.96C69.7453 1861.33 69.2149 1860.86 68.5333 1860.52C67.852 1860.17 67.0627 1860 66.1656 1860C65.2514 1860 64.4493 1860.17 63.7592 1860.52C63.0779 1860.86 62.5472 1861.33 62.1678 1861.96C61.7884 1862.57 61.6028 1863.3 61.6114 1864.13C61.6028 1864.97 61.7926 1865.71 62.1808 1866.34C62.5689 1866.97 63.1036 1867.46 63.7852 1867.81C64.4751 1868.17 65.2686 1868.34 66.1656 1868.34ZM85.246 1871.78V1883.58H81.3775V1857.09H85.1942V1866.95H85.4401C85.9057 1865.88 86.6174 1865.03 87.5749 1864.4C88.5323 1863.77 89.783 1863.45 91.3268 1863.45C92.6896 1863.45 93.88 1863.74 94.8978 1864.29C95.9242 1864.86 96.7177 1865.69 97.2785 1866.81C97.8476 1867.91 98.1323 1869.29 98.1323 1870.94C98.1323 1872.6 98.1323 1883.58 98.1323 1883.58H94.2638V1871.41C94.2638 1869.95 93.8886 1868.82 93.1383 1868.02C92.3878 1867.21 91.3442 1866.81 90.0071 1866.81C89.0929 1866.81 88.2734 1867 87.5488 1867.39C86.8329 1867.78 86.2679 1868.34 85.8539 1869.09C85.4487 1869.84 85.246 1870.73 85.246 1871.78ZM112.759 1883.98C110.896 1883.98 109.27 1883.55 107.881 1882.7C106.492 1881.85 105.414 1880.65 104.646 1879.12C103.879 1877.58 103.495 1875.79 103.495 1873.74C103.495 1871.68 103.879 1869.87 104.646 1868.33C105.414 1866.79 106.492 1865.59 107.881 1864.73C109.27 1863.88 110.896 1863.45 112.759 1863.45C114.622 1863.45 116.247 1863.88 117.636 1864.73C119.025 1865.59 120.103 1866.79 120.871 1868.33C121.638 1869.87 122.022 1871.68 122.022 1873.74C122.022 1875.79 121.638 1877.58 120.871 1879.12C120.103 1880.65 119.025 1881.85 117.636 1882.7C116.247 1883.55 114.622 1883.98 112.759 1883.98ZM112.771 1880.73C113.979 1880.73 114.98 1880.42 115.773 1879.78C116.567 1879.14 117.153 1878.29 117.533 1877.23C117.921 1876.17 118.115 1875 118.115 1873.73C118.115 1872.46 117.921 1871.29 117.533 1870.23C117.153 1869.16 116.567 1868.31 115.773 1867.66C114.98 1867.01 113.979 1866.69 112.771 1866.69C111.555 1866.69 110.546 1867.01 109.744 1867.66C108.951 1868.31 108.36 1869.16 107.972 1870.23C107.592 1871.29 107.402 1872.46 107.402 1873.73C107.402 1875 107.592 1876.17 107.972 1877.23C108.36 1878.29 108.951 1879.14 109.744 1879.78C110.546 1880.42 111.555 1880.73 112.771 1880.73ZM140.022 1875.34V1863.71H143.903V1883.58H140.1V1880.14H139.893C139.436 1881.2 138.703 1882.08 137.693 1882.79C136.693 1883.49 135.446 1883.84 133.954 1883.84C132.677 1883.84 131.548 1883.56 130.564 1883C129.59 1882.43 128.822 1881.59 128.261 1880.48C127.709 1879.36 127.433 1877.99 127.433 1876.35V1863.71H131.302V1875.88C131.302 1877.24 131.677 1878.32 132.427 1879.12C133.178 1879.92 134.153 1880.32 135.351 1880.32C136.076 1880.32 136.796 1880.14 137.512 1879.78C138.237 1879.42 138.836 1878.87 139.31 1878.14C139.793 1877.4 140.031 1876.47 140.022 1875.34ZM150.195 1883.58V1863.71H153.934V1866.87H154.141C154.503 1865.8 155.141 1864.96 156.055 1864.35C156.978 1863.73 158.022 1863.42 159.186 1863.42C159.428 1863.42 159.713 1863.43 160.04 1863.44C160.377 1863.46 160.64 1863.48 160.83 1863.51V1867.21C160.674 1867.16 160.398 1867.11 160.001 1867.06C159.605 1867 159.208 1866.97 158.811 1866.97C157.897 1866.97 157.082 1867.17 156.366 1867.55C155.659 1867.93 155.098 1868.46 154.684 1869.15C154.27 1869.82 154.063 1870.59 154.063 1871.45V1883.58H150.195ZM180.162 1868.56L176.656 1869.19C176.509 1868.74 176.276 1868.31 175.957 1867.9C175.647 1867.5 175.224 1867.17 174.689 1866.91C174.154 1866.65 173.486 1866.52 172.684 1866.52C171.588 1866.52 170.674 1866.77 169.941 1867.26C169.208 1867.74 168.841 1868.37 168.841 1869.13C168.841 1869.8 169.087 1870.33 169.579 1870.74C170.07 1871.14 170.864 1871.47 171.959 1871.73L175.116 1872.46C176.945 1872.88 178.308 1873.53 179.205 1874.41C180.102 1875.29 180.55 1876.43 180.55 1877.84C180.55 1879.03 180.205 1880.09 179.515 1881.02C178.834 1881.94 177.881 1882.67 176.656 1883.19C175.44 1883.72 174.029 1883.98 172.425 1883.98C170.2 1883.98 168.384 1883.51 166.978 1882.56C165.572 1881.6 164.71 1880.24 164.39 1878.48L168.13 1877.91C168.362 1878.89 168.841 1879.63 169.566 1880.13C170.29 1880.62 171.235 1880.86 172.399 1880.86C173.667 1880.86 174.681 1880.6 175.44 1880.08C176.199 1879.54 176.578 1878.89 176.578 1878.12C176.578 1877.5 176.345 1876.98 175.88 1876.56C175.423 1876.13 174.719 1875.81 173.771 1875.6L170.407 1874.86C168.552 1874.44 167.181 1873.77 166.293 1872.85C165.413 1871.92 164.973 1870.75 164.973 1869.34C164.973 1868.17 165.3 1867.14 165.956 1866.26C166.612 1865.38 167.517 1864.7 168.673 1864.21C169.829 1863.7 171.153 1863.45 172.645 1863.45C174.793 1863.45 176.483 1863.92 177.717 1864.85C178.95 1865.77 179.765 1867.01 180.162 1868.56ZM189.765 1857.09V1883.58H185.897V1857.09H189.765ZM201.846 1884.02C200.587 1884.02 199.448 1883.79 198.43 1883.32C197.412 1882.85 196.606 1882.16 196.011 1881.26C195.424 1880.37 195.131 1879.27 195.131 1877.97C195.131 1876.84 195.347 1875.92 195.778 1875.2C196.209 1874.47 196.791 1873.9 197.525 1873.48C198.258 1873.06 199.077 1872.74 199.983 1872.52C200.888 1872.3 201.811 1872.14 202.751 1872.03C203.942 1871.89 204.908 1871.78 205.65 1871.69C206.391 1871.6 206.93 1871.45 207.267 1871.24C207.603 1871.04 207.772 1870.7 207.772 1870.23V1870.14C207.772 1869.01 207.452 1868.14 206.814 1867.52C206.184 1866.9 205.244 1866.58 203.994 1866.58C202.691 1866.58 201.665 1866.87 200.914 1867.45C200.172 1868.02 199.659 1868.65 199.375 1869.35L195.739 1868.52C196.17 1867.32 196.8 1866.34 197.628 1865.6C198.465 1864.85 199.426 1864.31 200.513 1863.97C201.6 1863.63 202.743 1863.45 203.942 1863.45C204.735 1863.45 205.576 1863.55 206.465 1863.74C207.362 1863.92 208.199 1864.26 208.975 1864.75C209.76 1865.24 210.402 1865.94 210.902 1866.86C211.403 1867.76 211.653 1868.94 211.653 1870.39V1883.58H207.875V1880.86H207.72C207.47 1881.36 207.094 1881.86 206.594 1882.34C206.094 1882.82 205.451 1883.22 204.666 1883.54C203.881 1883.86 202.941 1884.02 201.846 1884.02ZM202.687 1880.92C203.756 1880.92 204.671 1880.71 205.43 1880.28C206.197 1879.86 206.779 1879.31 207.176 1878.63C207.582 1877.94 207.785 1877.2 207.785 1876.42V1873.85C207.646 1873.99 207.379 1874.12 206.982 1874.24C206.594 1874.36 206.15 1874.45 205.65 1874.54C205.149 1874.62 204.662 1874.69 204.188 1874.76C203.713 1874.82 203.316 1874.87 202.997 1874.91C202.247 1875.01 201.561 1875.17 200.94 1875.39C200.328 1875.62 199.836 1875.94 199.465 1876.36C199.103 1876.78 198.922 1877.33 198.922 1878.02C198.922 1878.98 199.275 1879.7 199.983 1880.19C200.69 1880.67 201.591 1880.92 202.687 1880.92ZM226.464 1883.98C224.506 1883.98 222.819 1883.56 221.405 1882.73C219.999 1881.88 218.912 1880.7 218.144 1879.17C217.385 1877.64 217.006 1875.84 217.006 1873.78C217.006 1871.74 217.385 1869.95 218.144 1868.39C218.912 1866.84 219.982 1865.63 221.353 1864.76C222.733 1863.89 224.346 1863.45 226.192 1863.45C227.313 1863.45 228.4 1863.64 229.452 1864.01C230.505 1864.38 231.449 1864.96 232.286 1865.76C233.122 1866.55 233.782 1867.58 234.265 1868.85C234.748 1870.11 234.99 1871.64 234.99 1873.44V1874.81H219.192V1871.91H231.199C231.199 1870.9 230.992 1869.99 230.578 1869.21C230.164 1868.42 229.582 1867.79 228.831 1867.34C228.09 1866.88 227.218 1866.65 226.218 1866.65C225.131 1866.65 224.182 1866.92 223.371 1867.45C222.569 1867.98 221.948 1868.67 221.508 1869.52C221.077 1870.37 220.861 1871.29 220.861 1872.28V1874.54C220.861 1875.87 221.094 1877 221.56 1877.93C222.035 1878.86 222.694 1879.57 223.54 1880.06C224.385 1880.54 225.372 1880.79 226.502 1880.79C227.235 1880.79 227.904 1880.68 228.508 1880.48C229.112 1880.26 229.633 1879.94 230.073 1879.52C230.513 1879.1 230.85 1878.57 231.083 1877.95L234.744 1878.61C234.451 1879.69 233.924 1880.64 233.166 1881.45C232.415 1882.25 231.471 1882.87 230.332 1883.32C229.202 1883.76 227.913 1883.98 226.464 1883.98ZM240.375 1883.58V1863.71H244.114V1866.87H244.321C244.683 1865.8 245.322 1864.96 246.236 1864.35C247.159 1863.73 248.203 1863.42 249.367 1863.42C249.609 1863.42 249.893 1863.43 250.221 1863.44C250.557 1863.46 250.82 1863.48 251.01 1863.51V1867.21C250.855 1867.16 250.579 1867.11 250.182 1867.06C249.785 1867 249.388 1866.97 248.992 1866.97C248.078 1866.97 247.262 1867.17 246.546 1867.55C245.839 1867.93 245.278 1868.46 244.864 1869.15C244.451 1869.82 244.243 1870.59 244.243 1871.45V1883.58H240.375ZM276.123 1883.58L280.469 1857.09H283.574L279.228 1883.58H276.123ZM264.129 1876.49L264.634 1873.39H284.714L284.209 1876.49H264.129ZM266.807 1883.58L271.155 1857.09H274.26L269.911 1883.58H266.807ZM265.669 1867.28L266.174 1864.18H286.252L285.75 1867.28H265.669ZM294.345 1871.78V1883.58H290.478V1863.71H294.189V1866.95H294.436C294.894 1865.89 295.61 1865.05 296.583 1864.41C297.567 1863.77 298.806 1863.45 300.297 1863.45C301.65 1863.45 302.837 1863.74 303.855 1864.31C304.872 1864.87 305.663 1865.7 306.223 1866.82C306.782 1867.93 307.063 1869.31 307.063 1870.94V1883.58H303.196V1871.41C303.196 1869.97 302.819 1868.84 302.069 1868.03C301.32 1867.21 300.289 1866.81 298.978 1866.81C298.08 1866.81 297.283 1867 296.583 1867.39C295.894 1867.78 295.347 1868.34 294.941 1869.09C294.543 1869.84 294.345 1870.73 294.345 1871.78ZM321.91 1883.98C319.953 1883.98 318.267 1883.56 316.851 1882.73C315.446 1881.88 314.358 1880.7 313.59 1879.17C312.833 1877.64 312.453 1875.84 312.453 1873.78C312.453 1871.74 312.833 1869.95 313.59 1868.39C314.358 1866.84 315.427 1865.63 316.799 1864.76C318.178 1863.89 319.792 1863.45 321.639 1863.45C322.758 1863.45 323.846 1863.64 324.898 1864.01C325.952 1864.38 326.896 1864.96 327.732 1865.76C328.57 1866.55 329.228 1867.58 329.712 1868.85C330.194 1870.11 330.436 1871.64 330.436 1873.44V1874.81H314.639V1871.91H326.646C326.646 1870.9 326.438 1869.99 326.024 1869.21C325.611 1868.42 325.028 1867.79 324.278 1867.34C323.537 1866.88 322.665 1866.65 321.665 1866.65C320.578 1866.65 319.628 1866.92 318.818 1867.45C318.017 1867.98 317.395 1868.67 316.955 1869.52C316.523 1870.37 316.307 1871.29 316.307 1872.28V1874.54C316.307 1875.87 316.541 1877 317.007 1877.93C317.481 1878.86 318.142 1879.57 318.985 1880.06C319.831 1880.54 320.82 1880.79 321.949 1880.79C322.683 1880.79 323.349 1880.68 323.953 1880.48C324.557 1880.26 325.08 1879.94 325.52 1879.52C325.959 1879.1 326.295 1878.57 326.529 1877.95L330.191 1878.61C329.897 1879.69 329.371 1880.64 328.611 1881.45C327.862 1882.25 326.917 1882.87 325.777 1883.32C324.648 1883.76 323.36 1883.98 321.91 1883.98ZM345.097 1863.71V1866.82H334.243V1863.71H345.097ZM337.155 1858.95H341.022V1877.75C341.022 1878.5 341.134 1879.06 341.358 1879.44C341.584 1879.81 341.873 1880.07 342.227 1880.2C342.589 1880.33 342.982 1880.4 343.403 1880.4C343.713 1880.4 343.986 1880.38 344.218 1880.33C344.452 1880.29 344.632 1880.26 344.762 1880.23L345.459 1883.42C345.235 1883.51 344.918 1883.6 344.504 1883.68C344.088 1883.78 343.572 1883.83 342.951 1883.84C341.933 1883.86 340.983 1883.67 340.103 1883.3C339.224 1882.92 338.513 1882.33 337.969 1881.54C337.426 1880.74 337.155 1879.75 337.155 1878.55V1858.95ZM359.929 1863.71V1866.82H348.697V1863.71H359.929ZM351.778 1883.58V1861.41C351.778 1860.17 352.049 1859.14 352.592 1858.32C353.136 1857.49 353.855 1856.87 354.752 1856.47C355.65 1856.06 356.624 1855.85 357.678 1855.85C358.453 1855.85 359.117 1855.91 359.668 1856.04C360.22 1856.16 360.631 1856.28 360.899 1856.38L359.994 1859.51C359.812 1859.46 359.58 1859.4 359.294 1859.33C359.01 1859.25 358.664 1859.21 358.258 1859.21C357.318 1859.21 356.647 1859.44 356.241 1859.91C355.843 1860.38 355.645 1861.05 355.645 1861.93V1883.58H351.778ZM368.881 1857.09V1883.58H365.014V1857.09H368.881ZM375.179 1883.58V1863.71H379.046V1883.58H375.179ZM377.133 1860.65C376.459 1860.65 375.881 1860.42 375.397 1859.98C374.924 1859.52 374.687 1858.97 374.687 1858.34C374.687 1857.71 374.924 1857.16 375.397 1856.72C375.881 1856.26 376.459 1856.03 377.133 1856.03C377.805 1856.03 378.377 1856.26 378.853 1856.72C379.335 1857.16 379.577 1857.71 379.577 1858.34C379.577 1858.97 379.335 1859.52 378.853 1859.98C378.377 1860.42 377.805 1860.65 377.133 1860.65ZM388.396 1863.71L392.784 1871.45L397.208 1863.71H401.44L395.241 1873.65L401.492 1883.58H397.26L392.784 1876.16L388.321 1883.58H384.076L390.26 1873.65L384.154 1863.71H388.396ZM427.175 1883.58L431.523 1857.09H434.628L430.282 1883.58H427.175ZM415.183 1876.49L415.688 1873.39H435.768L435.263 1876.49H415.183ZM417.861 1883.58L422.207 1857.09H425.311L420.965 1883.58H417.861ZM416.721 1867.28L417.226 1864.18H437.306L436.801 1867.28H416.721ZM445.4 1857.09V1883.58H441.53V1857.09H445.4ZM451.697 1883.58V1863.71H455.564V1883.58H451.697ZM453.649 1860.65C452.978 1860.65 452.4 1860.42 451.916 1859.98C451.442 1859.52 451.205 1858.97 451.205 1858.34C451.205 1857.71 451.442 1857.16 451.916 1856.72C452.4 1856.26 452.978 1856.03 453.649 1856.03C454.323 1856.03 454.896 1856.26 455.372 1856.72C455.853 1857.16 456.095 1857.71 456.095 1858.34C456.095 1858.97 455.853 1859.52 455.372 1859.98C454.896 1860.42 454.323 1860.65 453.649 1860.65ZM470.427 1883.98C468.47 1883.98 466.784 1883.56 465.368 1882.73C463.962 1881.88 462.875 1880.7 462.107 1879.17C461.35 1877.64 460.97 1875.84 460.97 1873.78C460.97 1871.74 461.35 1869.95 462.107 1868.39C462.875 1866.84 463.944 1865.63 465.316 1864.76C466.698 1863.89 468.309 1863.45 470.156 1863.45C471.278 1863.45 472.363 1863.64 473.417 1864.01C474.468 1864.38 475.413 1864.96 476.248 1865.76C477.086 1866.55 477.745 1867.58 478.229 1868.85C478.713 1870.11 478.952 1871.64 478.952 1873.44V1874.81H463.156V1871.91H475.163C475.163 1870.9 474.955 1869.99 474.541 1869.21C474.127 1868.42 473.545 1867.79 472.795 1867.34C472.053 1866.88 471.182 1866.65 470.182 1866.65C469.094 1866.65 468.145 1866.92 467.335 1867.45C466.534 1867.98 465.912 1868.67 465.472 1869.52C465.04 1870.37 464.824 1871.29 464.824 1872.28V1874.54C464.824 1875.87 465.058 1877 465.524 1877.93C465.998 1878.86 466.659 1879.57 467.504 1880.06C468.348 1880.54 469.336 1880.79 470.466 1880.79C471.2 1880.79 471.869 1880.68 472.472 1880.48C473.076 1880.26 473.597 1879.94 474.036 1879.52C474.476 1879.1 474.812 1878.57 475.046 1877.95L478.708 1878.61C478.414 1879.69 477.888 1880.64 477.128 1881.45C476.379 1882.25 475.434 1882.87 474.297 1883.32C473.165 1883.76 471.876 1883.98 470.427 1883.98ZM499.23 1868.56L495.725 1869.19C495.577 1868.74 495.345 1868.31 495.025 1867.9C494.715 1867.5 494.294 1867.17 493.757 1866.91C493.224 1866.65 492.555 1866.52 491.751 1866.52C490.658 1866.52 489.742 1866.77 489.008 1867.26C488.277 1867.74 487.91 1868.37 487.91 1869.13C487.91 1869.8 488.154 1870.33 488.646 1870.74C489.138 1871.14 489.932 1871.47 491.028 1871.73L494.184 1872.46C496.014 1872.88 497.375 1873.53 498.273 1874.41C499.17 1875.29 499.618 1876.43 499.618 1877.84C499.618 1879.03 499.275 1880.09 498.582 1881.02C497.903 1881.94 496.948 1882.67 495.725 1883.19C494.507 1883.72 493.096 1883.98 491.493 1883.98C489.268 1883.98 487.452 1883.51 486.047 1882.56C484.641 1881.6 483.777 1880.24 483.46 1878.48L487.197 1877.91C487.431 1878.89 487.91 1879.63 488.633 1880.13C489.359 1880.62 490.304 1880.86 491.467 1880.86C492.735 1880.86 493.75 1880.6 494.507 1880.08C495.267 1879.54 495.647 1878.89 495.647 1878.12C495.647 1877.5 495.413 1876.98 494.947 1876.56C494.491 1876.13 493.789 1875.81 492.839 1875.6L489.474 1874.86C487.621 1874.44 486.249 1873.77 485.359 1872.85C484.48 1871.92 484.04 1870.75 484.04 1869.34C484.04 1868.17 484.368 1867.14 485.024 1866.26C485.68 1865.38 486.585 1864.7 487.741 1864.21C488.896 1863.7 490.221 1863.45 491.715 1863.45C493.862 1863.45 495.551 1863.92 496.784 1864.85C498.018 1865.77 498.835 1867.01 499.23 1868.56Z"
+ fill="#BFBFBF"
+ />
+ <path
+ d="M994.565 1876.06C1023.31 1876.06 1046.61 1852.77 1046.61 1824.03C1046.61 1795.3 1023.31 1772 994.565 1772C965.819 1772 942.517 1795.3 942.517 1824.03C942.517 1852.77 965.819 1876.06 994.565 1876.06Z"
+ fill="#9D9D9D"
+ />
+ <path
+ d="M993.253 1599C997.49 1599.08 997.139 1599.29 997.139 1599.29L1006.1 1608.39L1011.97 1614.36C1020.55 1623.53 1022.53 1625.45 1029.01 1633.37C1026.99 1640.66 1022.2 1644.73 1016.89 1649.79L1014.55 1651.94L1010.41 1655.85L1006.99 1659.15L1002.3 1663.47C1002.3 1663.47 998.357 1667.12 995.939 1669.04C995.939 1669.04 993.43 1669.56 992.056 1668.18C991.957 1665.4 991.762 1660.81 991.793 1658.04C991.806 1656.52 991.817 1655 991.83 1653.43C991.845 1652.26 991.861 1651.08 991.879 1649.87C976.944 1649.3 973.915 1650.07 961.179 1658.94C958.909 1658.79 958.084 1656.73 958.084 1656.73C956.677 1644.97 961.9 1637.63 968.499 1627.87C975.089 1620.72 982.393 1618.15 991.879 1616.87C991.861 1615.7 991.845 1614.52 991.83 1613.31C991.817 1611.79 991.806 1610.28 991.793 1608.71C991.778 1607.2 991.76 1605.68 991.744 1604.12C991.879 1600.37 991.879 1600.37 993.253 1599Z"
+ fill="white"
+ fillOpacity="0.9"
+ />
+ <g filter="url(#filter0_d_5_2)">
+ <path
+ d="M965.181 1685.31V1708.02H962.43V1688.19H962.298L956.752 1691.87V1689.08L962.43 1685.31H965.181ZM974.318 1704.91L974.141 1706.11C974.016 1706.95 973.824 1707.85 973.566 1708.82C973.314 1709.78 973.051 1710.68 972.778 1711.53C972.504 1712.38 972.278 1713.06 972.101 1713.56H970.105C970.201 1713.09 970.326 1712.46 970.482 1711.68C970.638 1710.91 970.792 1710.04 970.948 1709.08C971.109 1708.13 971.242 1707.15 971.346 1706.15L971.479 1704.91H974.318ZM985.764 1708.33C984.462 1708.33 983.291 1708.07 982.248 1707.55C981.207 1707.03 980.371 1706.32 979.742 1705.42C979.114 1704.52 978.771 1703.49 978.711 1702.34H981.373C981.475 1703.37 981.941 1704.22 982.768 1704.89C983.604 1705.56 984.603 1705.89 985.764 1705.89C986.695 1705.89 987.523 1705.67 988.249 1705.23C988.98 1704.8 989.553 1704.2 989.966 1703.44C990.388 1702.67 990.599 1701.8 990.599 1700.83C990.599 1699.84 990.38 1698.96 989.946 1698.18C989.516 1697.4 988.926 1696.78 988.171 1696.33C987.416 1695.88 986.555 1695.65 985.587 1695.64C984.892 1695.64 984.179 1695.74 983.445 1695.97C982.714 1696.18 982.112 1696.46 981.639 1696.8L979.065 1696.49L980.442 1685.31H992.241V1687.75H982.747L981.948 1694.45H982.081C982.547 1694.08 983.133 1693.77 983.835 1693.53C984.535 1693.28 985.269 1693.16 986.029 1693.16C987.419 1693.16 988.657 1693.49 989.745 1694.16C990.838 1694.82 991.697 1695.72 992.319 1696.86C992.946 1698.01 993.261 1699.32 993.261 1700.79C993.261 1702.24 992.936 1703.53 992.285 1704.67C991.642 1705.8 990.755 1706.69 989.623 1707.35C988.491 1708 987.205 1708.33 985.764 1708.33ZM1005.21 1708.33C1004.27 1708.31 1003.34 1708.13 1002.41 1707.8C1001.48 1707.45 1000.63 1706.88 999.861 1706.08C999.093 1705.26 998.476 1704.17 998.011 1702.78C997.545 1701.39 997.311 1699.65 997.311 1697.55C997.311 1695.54 997.5 1693.76 997.878 1692.21C998.253 1690.65 998.802 1689.34 999.517 1688.27C1000.24 1687.2 1001.1 1686.39 1002.11 1685.83C1003.13 1685.28 1004.28 1685 1005.56 1685C1006.83 1685 1007.96 1685.25 1008.96 1685.76C1009.95 1686.27 1010.77 1686.97 1011.39 1687.87C1012.02 1688.77 1012.43 1689.81 1012.62 1690.99H1009.91C1009.66 1689.97 1009.17 1689.12 1008.45 1688.45C1007.72 1687.78 1006.76 1687.44 1005.56 1687.44C1003.8 1687.44 1002.42 1688.21 1001.4 1689.73C1000.4 1691.26 999.892 1693.41 999.884 1696.18H1000.06C1000.48 1695.55 1000.97 1695.01 1001.54 1694.57C1002.11 1694.12 1002.75 1693.77 1003.44 1693.53C1004.14 1693.28 1004.87 1693.16 1005.65 1693.16C1006.95 1693.16 1008.14 1693.49 1009.22 1694.14C1010.3 1694.78 1011.17 1695.67 1011.82 1696.81C1012.47 1697.94 1012.79 1699.24 1012.79 1700.7C1012.79 1702.1 1012.48 1703.39 1011.85 1704.56C1011.22 1705.72 1010.34 1706.64 1009.2 1707.33C1008.07 1708.01 1006.74 1708.34 1005.21 1708.33ZM1005.21 1705.89C1006.14 1705.89 1006.97 1705.65 1007.71 1705.19C1008.46 1704.72 1009.05 1704.1 1009.48 1703.32C1009.91 1702.53 1010.13 1701.66 1010.13 1700.7C1010.13 1699.76 1009.92 1698.91 1009.5 1698.14C1009.08 1697.36 1008.51 1696.75 1007.78 1696.29C1007.06 1695.83 1006.23 1695.6 1005.29 1695.6C1004.59 1695.6 1003.94 1695.74 1003.33 1696.02C1002.73 1696.29 1002.2 1696.67 1001.73 1697.15C1001.28 1697.63 1000.93 1698.18 1000.67 1698.8C1000.41 1699.42 1000.28 1700.06 1000.28 1700.74C1000.28 1701.65 1000.49 1702.49 1000.91 1703.27C1001.34 1704.05 1001.93 1704.69 1002.67 1705.17C1003.41 1705.65 1004.26 1705.89 1005.21 1705.89ZM1017.86 1708.02L1028.02 1687.93V1687.75H1016.31V1685.31H1030.86V1687.88L1020.75 1708.02H1017.86Z"
+ fill="white"
+ />
+ </g>
+ <g filter="url(#filter1_d_5_2)">
+ <path
+ d="M962.448 1531.36V1529.1L972.429 1513.31H974.071V1516.81H972.962L965.42 1528.74V1528.92H978.862V1531.36H962.448ZM973.139 1536.02V1530.67V1529.62V1513.31H975.757V1536.02H973.139ZM983.903 1536.19C983.353 1536.19 982.885 1536 982.492 1535.61C982.102 1535.22 981.906 1534.74 981.906 1534.2C981.906 1533.65 982.102 1533.18 982.492 1532.79C982.885 1532.4 983.353 1532.2 983.903 1532.2C984.449 1532.2 984.917 1532.4 985.31 1532.79C985.701 1533.18 985.899 1533.65 985.899 1534.2C985.899 1534.56 985.805 1534.89 985.62 1535.2C985.443 1535.5 985.204 1535.74 984.899 1535.93C984.603 1536.11 984.272 1536.19 983.903 1536.19ZM990.549 1536.02V1534.02L998.047 1525.82C998.926 1524.86 999.65 1524.02 1000.22 1523.31C1000.79 1522.59 1001.21 1521.92 1001.48 1521.29C1001.77 1520.66 1001.91 1519.99 1001.91 1519.3C1001.91 1518.5 1001.71 1517.81 1001.33 1517.22C1000.95 1516.64 1000.44 1516.19 999.777 1515.87C999.119 1515.55 998.38 1515.4 997.557 1515.4C996.686 1515.4 995.926 1515.58 995.275 1515.94C994.63 1516.29 994.133 1516.79 993.776 1517.44C993.43 1518.08 993.256 1518.83 993.256 1519.7H990.638C990.638 1518.37 990.945 1517.2 991.559 1516.19C992.173 1515.19 993.008 1514.4 994.065 1513.84C995.129 1513.28 996.324 1513 997.648 1513C998.978 1513 1000.16 1513.28 1001.19 1513.84C1002.21 1514.4 1003.02 1515.16 1003.6 1516.12C1004.19 1517.07 1004.48 1518.13 1004.48 1519.3C1004.48 1520.13 1004.33 1520.95 1004.02 1521.75C1003.73 1522.54 1003.21 1523.42 1002.47 1524.4C1001.74 1525.37 1000.72 1526.55 999.421 1527.95L994.32 1533.4V1533.58H1004.88V1536.02H990.549ZM1009.86 1536.02V1513.31H1012.61V1524.57H1012.87L1023.08 1513.31H1026.67L1017.13 1523.55L1026.67 1536.02H1023.34L1015.45 1525.46L1012.61 1528.65V1536.02H1009.86Z"
+ fill="white"
+ />
+ </g>
+ <path
+ d="M981.641 1429.12C982.123 1429.11 982.123 1429.11 984.551 1429.1C986.581 1429.09 988.611 1429.09 990.638 1429.1C993.74 1429.11 996.837 1429.08 999.936 1429.04C1001.91 1429.04 1003.88 1429.04 1005.86 1429.04C1006.78 1429.03 1007.71 1429.02 1008.66 1429C1012.71 1429.05 1015.52 1429.46 1019.05 1431.36C1022.18 1436.32 1021.68 1441.07 1021.55 1446.73C1021.54 1447.91 1021.54 1449.09 1021.53 1450.32C1021.5 1454.09 1021.43 1457.86 1021.37 1461.64C1021.34 1464.2 1021.32 1466.76 1021.3 1469.32C1021.24 1475.6 1021.92 1482.55 1021.81 1488.83C1021.81 1488.83 1021.68 1493.31 1015.86 1490.58C1014.61 1489.71 1013.36 1488.85 1012.08 1487.96C1010.42 1486.83 1008.77 1485.69 1007.12 1484.56C1006.29 1483.99 1005.47 1483.42 1004.63 1482.83C1003.82 1482.29 1003.02 1481.74 1002.2 1481.18C1001.46 1480.67 1000.73 1480.17 999.975 1479.65C997.428 1478.23 995.489 1477.5 992.54 1477.18C989.516 1479.06 986.87 1480.93 984.116 1483.12C975.26 1490.05 972.994 1492.06 967.443 1491.21C967.443 1491.21 967.591 1486.92 967.565 1482.89C967.56 1482.35 967.56 1482.35 967.529 1479.6C967.529 1478.42 967.529 1477.25 967.531 1476.04C967.526 1474.83 967.521 1473.63 967.513 1472.38C967.508 1469.83 967.505 1467.28 967.51 1464.73C967.516 1460.82 967.484 1456.91 967.453 1453C967.45 1450.52 967.448 1448.05 967.451 1445.57C967.438 1444.4 967.424 1443.23 967.414 1442.02C967.422 1440.93 967.43 1439.84 967.437 1438.71C967.437 1437.75 967.437 1436.8 967.435 1435.81C968.827 1428.62 975.185 1429.09 981.641 1429.12Z"
+ fill="white"
+ fillOpacity="0.9"
+ />
+ <g filter="url(#filter2_d_5_2)">
+ <path
+ d="M963.428 1365.33C962.126 1365.33 960.955 1365.07 959.912 1364.55C958.871 1364.03 958.035 1363.32 957.406 1362.42C956.778 1361.52 956.435 1360.49 956.375 1359.34H959.037C959.141 1360.37 959.607 1361.22 960.435 1361.89C961.27 1362.56 962.267 1362.89 963.428 1362.89C964.359 1362.89 965.187 1362.67 965.913 1362.23C966.644 1361.8 967.217 1361.2 967.63 1360.44C968.052 1359.67 968.263 1358.8 968.263 1357.83C968.263 1356.84 968.044 1355.96 967.61 1355.18C967.18 1354.4 966.589 1353.78 965.835 1353.33C965.08 1352.88 964.219 1352.65 963.251 1352.64C962.556 1352.64 961.843 1352.74 961.111 1352.97C960.378 1353.18 959.776 1353.46 959.303 1353.8L956.729 1353.49L958.106 1342.31H969.905V1344.75H960.411L959.612 1351.45H959.748C960.211 1351.08 960.796 1350.77 961.499 1350.53C962.202 1350.28 962.933 1350.16 963.696 1350.16C965.085 1350.16 966.321 1350.49 967.409 1351.16C968.505 1351.82 969.361 1352.72 969.983 1353.86C970.61 1355.01 970.925 1356.32 970.925 1357.79C970.925 1359.24 970.6 1360.53 969.949 1361.67C969.306 1362.8 968.419 1363.69 967.287 1364.35C966.157 1365 964.869 1365.33 963.428 1365.33ZM978.425 1361.91L978.248 1363.11C978.121 1363.95 977.928 1364.85 977.67 1365.82C977.418 1366.78 977.155 1367.68 976.882 1368.53C976.609 1369.38 976.385 1370.06 976.205 1370.56H974.209C974.306 1370.09 974.433 1369.46 974.587 1368.68C974.743 1367.91 974.899 1367.04 975.052 1366.08C975.216 1365.13 975.349 1364.15 975.453 1363.15L975.586 1361.91H978.425ZM988.499 1342.31V1365.02H985.748V1345.19H985.616L980.073 1348.87V1346.08L985.748 1342.31H988.499ZM994.5 1360.36V1358.1L1004.48 1342.31H1006.12V1345.81H1005.01L997.472 1357.74V1357.92H1010.91V1360.36H994.5ZM1005.19 1365.02V1359.67V1358.62V1342.31H1007.81V1365.02H1005.19ZM1022.34 1365.33C1020.82 1365.33 1019.47 1365.06 1018.31 1364.52C1017.15 1363.97 1016.24 1363.22 1015.59 1362.27C1014.94 1361.31 1014.62 1360.21 1014.62 1358.99C1014.62 1358.02 1014.8 1357.14 1015.19 1356.32C1015.57 1355.51 1016.1 1354.82 1016.76 1354.27C1017.44 1353.72 1018.19 1353.37 1019.02 1353.22V1353.09C1017.93 1352.81 1017.06 1352.2 1016.42 1351.26C1015.78 1350.31 1015.46 1349.24 1015.47 1348.03C1015.46 1346.88 1015.75 1345.85 1016.34 1344.94C1016.94 1344.03 1017.75 1343.31 1018.78 1342.79C1019.82 1342.26 1021.01 1342 1022.34 1342C1023.66 1342 1024.83 1342.26 1025.87 1342.79C1026.91 1343.31 1027.72 1344.03 1028.31 1344.94C1028.91 1345.85 1029.21 1346.88 1029.22 1348.03C1029.21 1349.24 1028.88 1350.31 1028.23 1351.26C1027.59 1352.2 1026.73 1352.81 1025.67 1353.09V1353.22C1026.49 1353.37 1027.23 1353.72 1027.89 1354.27C1028.55 1354.82 1029.07 1355.51 1029.46 1356.32C1029.85 1357.14 1030.05 1358.02 1030.06 1358.99C1030.05 1360.21 1029.72 1361.31 1029.06 1362.27C1028.41 1363.22 1027.51 1363.97 1026.35 1364.52C1025.19 1365.06 1023.86 1365.33 1022.34 1365.33ZM1022.34 1362.89C1023.37 1362.89 1024.26 1362.72 1025 1362.39C1025.75 1362.06 1026.33 1361.59 1026.73 1360.98C1027.14 1360.38 1027.35 1359.67 1027.36 1358.85C1027.35 1357.99 1027.13 1357.24 1026.69 1356.58C1026.25 1355.92 1025.66 1355.4 1024.9 1355.03C1024.16 1354.65 1023.3 1354.46 1022.34 1354.46C1021.38 1354.46 1020.51 1354.65 1019.75 1355.03C1018.99 1355.4 1018.4 1355.92 1017.96 1356.58C1017.53 1357.24 1017.32 1357.99 1017.33 1358.85C1017.32 1359.67 1017.52 1360.38 1017.92 1360.98C1018.33 1361.59 1018.91 1362.06 1019.66 1362.39C1020.41 1362.72 1021.31 1362.89 1022.34 1362.89ZM1022.34 1352.11C1023.16 1352.11 1023.88 1351.95 1024.51 1351.62C1025.14 1351.3 1025.64 1350.84 1026 1350.26C1026.36 1349.68 1026.55 1348.99 1026.56 1348.21C1026.55 1347.44 1026.37 1346.77 1026.01 1346.2C1025.66 1345.63 1025.17 1345.18 1024.54 1344.87C1023.91 1344.55 1023.18 1344.4 1022.34 1344.4C1021.49 1344.4 1020.75 1344.55 1020.11 1344.87C1019.48 1345.18 1018.99 1345.63 1018.64 1346.2C1018.29 1346.77 1018.12 1347.44 1018.13 1348.21C1018.12 1348.99 1018.29 1349.68 1018.65 1350.26C1019.01 1350.84 1019.51 1351.3 1020.15 1351.62C1020.78 1351.95 1021.52 1352.11 1022.34 1352.11Z"
+ fill="white"
+ />
+ </g>
+ <path
+ fillRule="evenodd"
+ clipRule="evenodd"
+ d="M977.188 1256.62C988.956 1252.25 1004.57 1253.5 1015.53 1259.22C1022.37 1263.77 1028.69 1269.31 1031.15 1277.23C1032.49 1287.77 1032.28 1297.64 1025.47 1306.32C1020.61 1311.36 1015.45 1315.75 1009.85 1320C1008.42 1321.11 1006.98 1322.22 1005.5 1323.36C1001.33 1325.71 1001.27 1325.82 998.234 1326.81C997.737 1326.63 998.414 1326.92 995.895 1326.04C995.66 1324.9 995.673 1323.02 995.452 1317.87C993.813 1317.75 989.532 1317.17 987.84 1317.05C976.496 1315.59 969.272 1310.69 962.152 1302.07C956.676 1294.74 956.213 1287.47 957.309 1278.61C960.494 1268.07 967.95 1262.26 977.188 1256.62ZM976.374 1282.47C973.498 1282.47 971.169 1284.8 971.169 1287.68C971.169 1290.55 973.498 1292.88 976.374 1292.88C979.247 1292.88 981.579 1290.55 981.579 1287.68C981.579 1284.8 979.25 1282.47 976.374 1282.47ZM994.552 1282.47C991.679 1282.47 989.347 1284.8 989.347 1287.68C989.347 1290.55 991.679 1292.88 994.552 1292.88C997.427 1292.88 999.756 1290.55 999.756 1287.68C999.756 1284.8 997.427 1282.47 994.552 1282.47ZM1012.73 1282.47C1009.85 1282.47 1007.52 1284.8 1007.52 1287.68C1007.52 1290.55 1009.85 1292.88 1012.73 1292.88C1015.6 1292.88 1017.93 1290.55 1017.93 1287.68C1017.93 1284.8 1015.6 1282.47 1012.73 1282.47Z"
+ fill="white"
+ fillOpacity="0.9"
+ />
+ <g filter="url(#filter3_d_5_2)">
+ <path
+ d="M947.525 1193.06V1190.68L954.89 1183.05C955.676 1182.22 956.324 1181.49 956.834 1180.87C957.352 1180.24 957.737 1179.64 957.992 1179.07C958.247 1178.51 958.375 1177.9 958.375 1177.27C958.375 1176.54 958.206 1175.92 957.865 1175.4C957.524 1174.87 957.061 1174.46 956.472 1174.18C955.884 1173.89 955.223 1173.74 954.484 1173.74C953.706 1173.74 953.027 1173.9 952.444 1174.22C951.864 1174.54 951.419 1174.99 951.106 1175.57C950.794 1176.15 950.638 1176.83 950.638 1177.61H947.502C947.502 1176.28 947.806 1175.12 948.418 1174.13C949.027 1173.14 949.862 1172.37 950.924 1171.83C951.989 1171.28 953.196 1171 954.549 1171C955.916 1171 957.12 1171.27 958.161 1171.82C959.21 1172.36 960.03 1173.09 960.618 1174.03C961.206 1174.95 961.5 1176 961.5 1177.17C961.5 1177.98 961.347 1178.77 961.042 1179.54C960.746 1180.31 960.225 1181.17 959.481 1182.12C958.737 1183.06 957.701 1184.21 956.376 1185.55L952.051 1190.08V1190.24H961.852V1193.06H947.525ZM973.063 1193.35C971.73 1193.35 970.533 1193.1 969.471 1192.59C968.415 1192.07 967.572 1191.36 966.942 1190.46C966.31 1189.56 965.974 1188.54 965.932 1187.38H969.12C969.198 1188.32 969.612 1189.09 970.364 1189.69C971.114 1190.29 972.014 1190.59 973.063 1190.59C973.898 1190.59 974.64 1190.4 975.283 1190.02C975.936 1189.63 976.446 1189.09 976.815 1188.41C977.19 1187.73 977.378 1186.96 977.378 1186.09C977.378 1185.2 977.185 1184.41 976.802 1183.72C976.42 1183.02 975.894 1182.48 975.22 1182.08C974.554 1181.68 973.789 1181.48 972.925 1181.47C972.266 1181.47 971.603 1181.59 970.937 1181.81C970.27 1182.04 969.732 1182.34 969.321 1182.71L966.315 1182.26L967.535 1171.3H979.504V1174.11H970.268L969.576 1180.2H969.703C970.13 1179.79 970.692 1179.44 971.395 1179.17C972.103 1178.89 972.86 1178.76 973.669 1178.76C974.994 1178.76 976.173 1179.07 977.208 1179.7C978.249 1180.33 979.067 1181.19 979.663 1182.28C980.264 1183.37 980.563 1184.61 980.555 1186.02C980.563 1187.43 980.243 1188.69 979.597 1189.79C978.96 1190.9 978.075 1191.77 976.943 1192.41C975.816 1193.04 974.523 1193.35 973.063 1193.35ZM991.792 1193.35C990.267 1193.35 988.922 1193.09 987.753 1192.57C986.59 1192.04 985.679 1191.32 985.021 1190.4C984.362 1189.48 984.037 1188.43 984.045 1187.25C984.037 1186.33 984.225 1185.49 984.607 1184.72C984.997 1183.94 985.526 1183.29 986.189 1182.77C986.856 1182.25 987.6 1181.91 988.422 1181.77V1181.65C987.34 1181.38 986.47 1180.8 985.82 1179.9C985.167 1179 984.844 1177.97 984.852 1176.8C984.844 1175.69 985.138 1174.7 985.734 1173.83C986.335 1172.95 987.16 1172.26 988.209 1171.75C989.258 1171.25 990.452 1171 991.792 1171C993.117 1171 994.298 1171.25 995.342 1171.76C996.391 1172.27 997.216 1172.96 997.817 1173.84C998.421 1174.71 998.725 1175.7 998.73 1176.8C998.725 1177.97 998.392 1179 997.731 1179.9C997.073 1180.8 996.216 1181.38 995.16 1181.65V1181.77C995.974 1181.91 996.708 1182.25 997.361 1182.77C998.02 1183.29 998.543 1183.94 998.933 1184.72C999.329 1185.49 999.532 1186.33 999.54 1187.25C999.532 1188.43 999.199 1189.48 998.54 1190.4C997.882 1191.32 996.966 1192.04 995.797 1192.57C994.637 1193.09 993.302 1193.35 991.792 1193.35ZM991.792 1190.67C992.69 1190.67 993.471 1190.52 994.129 1190.22C994.788 1189.91 995.298 1189.49 995.66 1188.95C996.021 1188.41 996.206 1187.77 996.211 1187.04C996.206 1186.28 996.008 1185.62 995.618 1185.03C995.235 1184.45 994.715 1184 994.054 1183.66C993.395 1183.33 992.641 1183.17 991.792 1183.17C990.933 1183.17 990.174 1183.33 989.507 1183.66C988.841 1184 988.315 1184.45 987.933 1185.03C987.55 1185.62 987.363 1186.28 987.371 1187.04C987.363 1187.77 987.537 1188.41 987.891 1188.95C988.253 1189.49 988.766 1189.91 989.432 1190.22C990.098 1190.52 990.884 1190.67 991.792 1190.67ZM991.792 1180.54C992.513 1180.54 993.156 1180.39 993.715 1180.1C994.275 1179.81 994.715 1179.41 995.032 1178.89C995.358 1178.38 995.524 1177.77 995.532 1177.08C995.524 1176.4 995.363 1175.8 995.043 1175.29C994.73 1174.78 994.296 1174.39 993.736 1174.11C993.177 1173.83 992.529 1173.69 991.792 1173.69C991.04 1173.69 990.382 1173.83 989.815 1174.11C989.255 1174.39 988.82 1174.78 988.508 1175.29C988.196 1175.8 988.042 1176.4 988.05 1177.08C988.042 1177.77 988.198 1178.38 988.518 1178.89C988.836 1179.41 989.276 1179.81 989.835 1180.1C990.403 1180.39 991.053 1180.54 991.792 1180.54ZM1004.84 1193.26C1004.26 1193.26 1003.76 1193.05 1003.34 1192.64C1002.92 1192.22 1002.71 1191.72 1002.71 1191.13C1002.71 1190.55 1002.92 1190.06 1003.34 1189.65C1003.76 1189.23 1004.26 1189.02 1004.84 1189.02C1005.42 1189.02 1005.92 1189.23 1006.34 1189.65C1006.76 1190.06 1006.96 1190.55 1006.96 1191.13C1006.96 1191.52 1006.87 1191.88 1006.67 1192.21C1006.48 1192.52 1006.22 1192.78 1005.9 1192.97C1005.58 1193.16 1005.23 1193.26 1004.84 1193.26ZM1016.81 1171.3V1193.06H1013.51V1174.59H1013.38L1008.18 1177.99V1174.85L1013.61 1171.3H1016.81ZM1022.99 1193.06V1171.3H1026.28V1181.69H1026.54L1035.67 1171.3H1039.8L1031.03 1181.09L1039.84 1193.06H1035.88L1028.85 1183.33L1026.28 1186.29V1193.06H1022.99Z"
+ fill="white"
+ />
+ </g>
+ <path
+ d="M988.553 1150.16C977.873 1141.93 955.578 1127.87 956.619 1105.96C956.619 1094.76 965.067 1085.02 976.051 1085.02C981.746 1085.02 987.12 1085 994.708 1092.81C1001.48 1084.91 1007.48 1085 1013.17 1085C1024.16 1085 1032.52 1094.76 1032.52 1105.96C1033.34 1127.56 1011.24 1143.14 1000.59 1150.16C996.579 1152.24 991.838 1151.72 988.553 1150.16Z"
+ fill="white"
+ fillOpacity="0.9"
+ />
+ <path
+ d="M994.631 876.5C1026.17 876.5 1051.73 902.056 1051.73 933.58C1051.73 965.105 1026.17 990.661 994.631 990.661C963.097 990.661 937.533 965.105 937.533 933.58C937.533 902.056 963.097 876.5 994.631 876.5Z"
+ fill="#9D9D9D"
+ stroke="white"
+ />
+ <path
+ d="M993.947 1019.18C1009.74 1019.18 1022.55 1006.38 1022.55 990.592C1022.55 974.801 1009.74 962 993.947 962C978.152 962 965.347 974.801 965.347 990.592C965.347 1006.38 978.152 1019.18 993.947 1019.18Z"
+ fill="#FE2C55"
+ />
+ <path
+ d="M1008.87 988.577H980.264V993.747H1008.87V988.577Z"
+ fill="white"
+ />
+ <path
+ d="M991.979 976.864V1005.46H997.151V976.864H991.979Z"
+ fill="white"
+ />
+ <path
+ d="M998.114 252.67C1009.71 252.67 1019.12 243.27 1019.12 231.675C1019.12 220.08 1009.71 210.68 998.114 210.68C986.516 210.68 977.113 220.08 977.113 231.675C977.113 243.27 986.516 252.67 998.114 252.67Z"
+ stroke="white"
+ strokeWidth="4"
+ />
+ <path
+ d="M1015.72 246.31C1014.9 245.494 1013.58 245.494 1012.76 246.31C1011.94 247.126 1011.94 248.449 1012.76 249.265L1024.78 261.282C1025.6 262.098 1026.92 262.098 1027.74 261.282C1028.55 260.466 1028.55 259.143 1027.74 258.327L1015.72 246.31Z"
+ fill="white"
+ />
+ <path
+ d="M791.47 268H734.394C732.965 268 731.807 269.158 731.807 270.587V270.587C731.807 272.015 732.965 273.174 734.394 273.174H791.47C792.899 273.174 794.058 272.015 794.058 270.587C794.058 269.158 792.899 268 791.47 268Z"
+ fill="white"
+ />
+ <path
+ d="M686.65 236.274V206H706.051V210.597H692.136V218.816H704.722V223.413H692.136V236.274H686.65ZM720.094 236.717C717.874 236.717 715.954 236.229 714.327 235.254C712.701 234.278 711.438 232.913 710.541 231.159C709.653 229.405 709.211 227.355 709.211 225.01C709.211 222.664 709.653 220.61 710.541 218.846C711.438 217.082 712.701 215.712 714.327 214.736C715.954 213.761 717.874 213.273 720.094 213.273C722.311 213.273 724.234 213.761 725.861 214.736C727.487 215.712 728.744 217.082 729.629 218.846C730.527 220.61 730.975 222.664 730.975 225.01C730.975 227.355 730.527 229.405 729.629 231.159C728.744 232.913 727.487 234.278 725.861 235.254C724.234 236.229 722.311 236.717 720.094 236.717ZM720.123 232.43C721.325 232.43 722.332 232.1 723.139 231.44C723.948 230.77 724.549 229.873 724.942 228.75C725.348 227.626 725.549 226.375 725.549 224.995C725.549 223.605 725.348 222.349 724.942 221.226C724.549 220.092 723.948 219.191 723.139 218.52C722.332 217.85 721.325 217.515 720.123 217.515C718.892 217.515 717.866 217.85 717.047 218.52C716.24 219.191 715.633 220.092 715.228 221.226C714.835 222.349 714.637 223.605 714.637 224.995C714.637 226.375 714.835 227.626 715.228 228.75C715.633 229.873 716.24 230.77 717.047 231.44C717.866 232.1 718.892 232.43 720.123 232.43ZM736.559 236.274V213.568H741.751V217.353H741.985C742.402 216.042 743.11 215.032 744.117 214.322C745.132 213.603 746.29 213.243 747.591 213.243C747.885 213.243 748.216 213.258 748.583 213.288C748.955 213.307 749.267 213.342 749.512 213.391V218.314C749.285 218.235 748.926 218.166 748.434 218.106C747.95 218.037 747.482 218.003 747.029 218.003C746.053 218.003 745.176 218.215 744.398 218.639C743.628 219.053 743.021 219.629 742.579 220.368C742.134 221.107 741.913 221.96 741.913 222.925V236.274H736.559ZM763.645 206H769.842L777.248 219.393H777.545L784.954 206H791.148L780.132 224.98V236.274H774.661V224.98L763.645 206ZM801.516 236.717C799.298 236.717 797.375 236.229 795.749 235.254C794.122 234.278 792.86 232.913 791.965 231.159C791.077 229.405 790.632 227.355 790.632 225.01C790.632 222.664 791.077 220.61 791.965 218.846C792.86 217.082 794.122 215.712 795.749 214.736C797.375 213.761 799.298 213.273 801.516 213.273C803.733 213.273 805.656 213.761 807.283 214.736C808.909 215.712 810.166 217.082 811.053 218.846C811.951 220.61 812.399 222.664 812.399 225.01C812.399 227.355 811.951 229.405 811.053 231.159C810.166 232.913 808.909 234.278 807.283 235.254C805.656 236.229 803.733 236.717 801.516 236.717ZM801.547 232.43C802.749 232.43 803.754 232.1 804.563 231.44C805.37 230.77 805.971 229.873 806.367 228.75C806.77 227.626 806.973 226.375 806.973 224.995C806.973 223.605 806.77 222.349 806.367 221.226C805.971 220.092 805.37 219.191 804.563 218.52C803.754 217.85 802.749 217.515 801.547 217.515C800.313 217.515 799.288 217.85 798.471 218.52C797.661 219.191 797.055 220.092 796.652 221.226C796.256 222.349 796.061 223.605 796.061 224.995C796.061 226.375 796.256 227.626 796.652 228.75C797.055 229.873 797.661 230.77 798.471 231.44C799.288 232.1 800.313 232.43 801.547 232.43ZM832.458 226.724V213.568H837.812V236.274H832.622V232.238H832.385C831.873 233.509 831.03 234.549 829.856 235.357C828.693 236.165 827.259 236.569 825.554 236.569C824.066 236.569 822.749 236.239 821.606 235.579C820.472 234.909 819.584 233.938 818.944 232.667C818.304 231.386 817.984 229.838 817.984 228.025V213.568H823.337V227.198C823.337 228.636 823.73 229.779 824.518 230.627C825.307 231.474 826.343 231.898 827.623 231.898C828.412 231.898 829.177 231.706 829.916 231.322C830.655 230.937 831.261 230.366 831.735 229.607C832.216 228.838 832.458 227.877 832.458 226.724Z"
+ fill="white"
+ />
+ <path
+ d="M439.279 236.486V206.212H458.058V210.144H443.847V219.368H456.71V223.285H443.847V236.486H439.279ZM472.262 236.944C470.133 236.944 468.275 236.456 466.688 235.48C465.1 234.505 463.867 233.14 462.99 231.386C462.113 229.632 461.676 227.582 461.676 225.236C461.676 222.881 462.113 220.822 462.99 219.058C463.867 217.294 465.1 215.924 466.688 214.948C468.275 213.973 470.133 213.485 472.262 213.485C474.391 213.485 476.249 213.973 477.837 214.948C479.424 215.924 480.655 217.294 481.532 219.058C482.409 220.822 482.849 222.881 482.849 225.236C482.849 227.582 482.409 229.632 481.532 231.386C480.655 233.14 479.424 234.505 477.837 235.48C476.249 236.456 474.391 236.944 472.262 236.944ZM472.275 233.234C473.657 233.234 474.8 232.869 475.708 232.14C476.613 231.41 477.285 230.44 477.717 229.228C478.162 228.016 478.383 226.68 478.383 225.222C478.383 223.773 478.162 222.443 477.717 221.231C477.285 220.009 476.613 219.028 475.708 218.289C474.8 217.55 473.657 217.18 472.275 217.18C470.886 217.18 469.733 217.55 468.817 218.289C467.908 219.028 467.234 220.009 466.792 221.231C466.357 222.443 466.141 223.773 466.141 225.222C466.141 226.68 466.357 228.016 466.792 229.228C467.234 230.44 467.908 231.41 468.817 232.14C469.733 232.869 470.886 233.234 472.275 233.234ZM493.245 206.212V236.486H488.824V206.212H493.245ZM504.657 206.212V236.486H500.236V206.212H504.657ZM521.211 236.944C519.082 236.944 517.224 236.456 515.637 235.48C514.049 234.505 512.818 233.14 511.941 231.386C511.064 229.632 510.624 227.582 510.624 225.236C510.624 222.881 511.064 220.822 511.941 219.058C512.818 217.294 514.049 215.924 515.637 214.948C517.224 213.973 519.082 213.485 521.211 213.485C523.34 213.485 525.198 213.973 526.785 214.948C528.373 215.924 529.606 217.294 530.483 219.058C531.36 220.822 531.798 222.881 531.798 225.236C531.798 227.582 531.36 229.632 530.483 231.386C529.606 233.14 528.373 234.505 526.785 235.48C525.198 236.456 523.34 236.944 521.211 236.944ZM521.227 233.234C522.606 233.234 523.751 232.869 524.657 232.14C525.562 231.41 526.234 230.44 526.668 229.228C527.111 228.016 527.332 226.68 527.332 225.222C527.332 223.773 527.111 222.443 526.668 221.231C526.234 220.009 525.562 219.028 524.657 218.289C523.751 217.55 522.606 217.18 521.227 217.18C519.837 217.18 518.684 217.55 517.765 218.289C516.86 219.028 516.183 220.009 515.741 221.231C515.306 222.443 515.09 223.773 515.09 225.222C515.09 226.68 515.306 228.016 515.741 229.228C516.183 230.44 516.86 231.41 517.765 232.14C518.684 232.869 519.837 233.234 521.227 233.234ZM542.007 236.486L535.324 213.78H539.894L544.344 230.455H544.565L549.031 213.78H553.601L558.035 230.381H558.256L562.678 213.78H567.248L560.578 236.486H556.068L551.456 220.093H551.115L546.501 236.486H542.007ZM572.484 236.486V213.78H576.905V236.486H572.484ZM574.716 210.277C573.949 210.277 573.288 210.021 572.736 209.509C572.192 208.986 571.922 208.365 571.922 207.646C571.922 206.917 572.192 206.296 572.736 205.784C573.288 205.261 573.949 205 574.716 205C575.487 205 576.14 205.261 576.684 205.784C577.236 206.296 577.511 206.917 577.511 207.646C577.511 208.365 577.236 208.986 576.684 209.509C576.14 210.021 575.487 210.277 574.716 210.277ZM588.314 223.005V236.486H583.895V213.78H588.137V217.476H588.418C588.941 216.274 589.758 215.308 590.872 214.579C591.996 213.849 593.412 213.485 595.117 213.485C596.665 213.485 598.021 213.81 599.184 214.461C600.345 215.101 601.248 216.057 601.888 217.328C602.528 218.599 602.851 220.171 602.851 222.044V236.486H598.43V222.576C598.43 220.93 598 219.644 597.141 218.718C596.285 217.782 595.106 217.313 593.607 217.313C592.585 217.313 591.671 217.535 590.872 217.979C590.084 218.422 589.459 219.072 588.996 219.93C588.54 220.777 588.314 221.802 588.314 223.005ZM619.343 245.473C617.539 245.473 615.985 245.236 614.684 244.763C613.393 244.291 612.339 243.665 611.52 242.886C610.703 242.108 610.091 241.255 609.688 240.329L613.487 238.762C613.753 239.196 614.109 239.654 614.552 240.137C615.004 240.63 615.616 241.048 616.386 241.393C617.164 241.738 618.164 241.911 619.387 241.911C621.063 241.911 622.447 241.502 623.543 240.684C624.636 239.876 625.182 238.585 625.182 236.811V232.347H624.901C624.636 232.83 624.251 233.367 623.748 233.958C623.257 234.549 622.577 235.062 621.708 235.495C620.842 235.929 619.712 236.146 618.322 236.146C616.529 236.146 614.911 235.727 613.472 234.889C612.043 234.042 610.911 232.795 610.073 231.149C609.243 229.494 608.829 227.459 608.829 225.044C608.829 222.63 609.24 220.561 610.057 218.836C610.885 217.111 612.019 215.791 613.459 214.874C614.898 213.948 616.529 213.485 618.351 213.485C619.762 213.485 620.901 213.721 621.768 214.194C622.635 214.657 623.311 215.2 623.793 215.82C624.287 216.441 624.667 216.988 624.933 217.461H625.258V213.78H629.591V236.988C629.591 238.939 629.135 240.541 628.23 241.792C627.322 243.044 626.096 243.97 624.547 244.571C623.009 245.172 621.276 245.473 619.343 245.473ZM619.298 232.48C620.571 232.48 621.646 232.184 622.523 231.593C623.41 230.992 624.079 230.134 624.532 229.021C624.995 227.897 625.227 226.552 625.227 224.985C625.227 223.458 625 222.113 624.547 220.95C624.095 219.787 623.428 218.88 622.551 218.23C621.674 217.57 620.589 217.24 619.298 217.24C617.969 217.24 616.86 217.584 615.972 218.274C615.085 218.954 614.414 219.881 613.961 221.053C613.516 222.226 613.295 223.537 613.295 224.985C613.295 226.473 613.521 227.779 613.976 228.902C614.429 230.026 615.098 230.903 615.985 231.534C616.883 232.164 617.987 232.48 619.298 232.48Z"
+ fill="#C0C0C0"
+ />
+ <path
+ d="M234.87 236.274V206H253.856V209.932H239.439V219.156H252.865V223.073H239.439V232.341H254.033V236.274H234.87ZM264.134 213.568L269.147 222.408L274.203 213.568H279.039L271.957 224.921L279.098 236.274H274.263L269.147 227.789L264.046 236.274H259.196L266.263 224.921L259.285 213.568H264.134ZM284.621 244.788V213.568H288.938V217.249H289.308C289.563 216.776 289.932 216.229 290.416 215.608C290.898 214.988 291.569 214.445 292.428 213.982C293.284 213.509 294.419 213.273 295.827 213.273C297.661 213.273 299.298 213.736 300.737 214.662C302.177 215.589 303.306 216.924 304.123 218.668C304.951 220.412 305.364 222.511 305.364 224.965C305.364 227.419 304.956 229.523 304.139 231.277C303.319 233.022 302.195 234.367 300.766 235.313C299.337 236.249 297.706 236.717 295.871 236.717C294.492 236.717 293.362 236.485 292.485 236.022C291.619 235.559 290.939 235.017 290.445 234.396C289.953 233.775 289.573 233.223 289.308 232.741H289.04V244.788H284.621ZM288.951 224.921C288.951 226.517 289.183 227.917 289.646 229.119C290.109 230.321 290.781 231.263 291.658 231.942C292.535 232.613 293.609 232.948 294.882 232.948C296.201 232.948 297.307 232.598 298.195 231.898C299.08 231.189 299.751 230.228 300.204 229.016C300.667 227.803 300.899 226.439 300.899 224.921C300.899 223.423 300.672 222.078 300.22 220.885C299.775 219.693 299.106 218.752 298.208 218.062C297.32 217.372 296.212 217.028 294.882 217.028C293.599 217.028 292.516 217.358 291.629 218.018C290.752 218.678 290.086 219.599 289.633 220.782C289.18 221.965 288.951 223.344 288.951 224.921ZM315.779 206V236.274H311.36V206H315.779ZM332.336 236.732C330.207 236.732 328.349 236.244 326.762 235.268C325.174 234.293 323.943 232.928 323.066 231.174C322.189 229.42 321.749 227.37 321.749 225.024C321.749 222.669 322.189 220.61 323.066 218.846C323.943 217.082 325.174 215.712 326.762 214.736C328.349 213.761 330.207 213.273 332.336 213.273C334.465 213.273 336.323 213.761 337.91 214.736C339.498 215.712 340.731 217.082 341.608 218.846C342.485 220.61 342.922 222.669 342.922 225.024C342.922 227.37 342.485 229.42 341.608 231.174C340.731 232.928 339.498 234.293 337.91 235.268C336.323 236.244 334.465 236.732 332.336 236.732ZM332.352 233.022C333.731 233.022 334.876 232.657 335.781 231.928C336.687 231.198 337.359 230.228 337.793 229.016C338.236 227.803 338.457 226.468 338.457 225.01C338.457 223.561 338.236 222.231 337.793 221.019C337.359 219.797 336.687 218.816 335.781 218.077C334.876 217.338 333.731 216.968 332.352 216.968C330.962 216.968 329.809 217.338 328.89 218.077C327.985 218.816 327.308 219.797 326.866 221.019C326.431 222.231 326.215 223.561 326.215 225.01C326.215 226.468 326.431 227.803 326.866 229.016C327.308 230.228 327.985 231.198 328.89 231.928C329.809 232.657 330.962 233.022 332.352 233.022ZM348.9 236.274V213.568H353.173V217.175H353.41C353.824 215.953 354.553 214.993 355.596 214.293C356.653 213.583 357.845 213.228 359.175 213.228C359.453 213.228 359.778 213.238 360.15 213.258C360.536 213.278 360.838 213.302 361.054 213.332V217.56C360.877 217.51 360.562 217.456 360.106 217.397C359.653 217.328 359.201 217.293 358.748 217.293C357.702 217.293 356.77 217.515 355.953 217.959C355.143 218.392 354.503 218.998 354.03 219.777C353.556 220.545 353.319 221.423 353.319 222.408V236.274H348.9ZM374.896 236.732C372.658 236.732 370.732 236.254 369.116 235.298C367.507 234.332 366.266 232.977 365.389 231.233C364.52 229.479 364.088 227.424 364.088 225.069C364.088 222.743 364.52 220.693 365.389 218.919C366.266 217.146 367.489 215.761 369.056 214.766C370.633 213.771 372.475 213.273 374.586 213.273C375.866 213.273 377.108 213.485 378.313 213.908C379.515 214.332 380.592 214.997 381.55 215.904C382.505 216.811 383.26 217.988 383.812 219.437C384.363 220.876 384.639 222.625 384.639 224.685V226.251H366.586V222.94H380.309C380.309 221.777 380.072 220.748 379.598 219.851C379.125 218.944 378.458 218.23 377.602 217.707C376.754 217.185 375.76 216.924 374.615 216.924C373.373 216.924 372.288 217.229 371.362 217.841C370.446 218.441 369.735 219.23 369.233 220.206C368.741 221.171 368.494 222.221 368.494 223.354V225.941C368.494 227.458 368.759 228.75 369.293 229.814C369.834 230.878 370.589 231.691 371.554 232.253C372.52 232.805 373.649 233.081 374.94 233.081C375.778 233.081 376.543 232.962 377.233 232.726C377.922 232.48 378.518 232.115 379.021 231.632C379.525 231.149 379.908 230.553 380.176 229.843L384.358 230.597C384.025 231.829 383.424 232.908 382.555 233.835C381.698 234.751 380.618 235.465 379.317 235.978C378.026 236.48 376.551 236.732 374.896 236.732Z"
+ fill="#C0C0C0"
+ />
+ <path
+ d="M96.4754 244.508C96.4754 247.382 94.1452 249.712 91.2705 249.712H47.4236C44.5489 249.712 42.2188 247.382 42.2188 244.508V239.399H96.4754V244.508ZM47.3676 239.45V244.328H91.4358V239.45H47.3676ZM91.2705 199.109C94.1452 199.109 96.4754 201.439 96.4754 204.313V209.173H42.2188V204.313C42.2188 201.439 44.5489 199.11 47.4236 199.109H91.2705ZM47.3676 204.262V209.14H91.4358V204.262H47.3676Z"
+ fill="white"
+ />
+ <path
+ d="M39.3633 235.03V214.217H44.3953V230.945H53.0564V235.03H39.3633ZM60.6934 214.217V235.03H55.6614V214.217H60.6934ZM68.5795 214.217L73.2352 229.461H73.408L78.0639 214.217H83.6956L76.6711 235.03H69.972L62.9476 214.217H68.5795ZM85.9373 235.03V214.217H100.454V218.303H90.9693V222.571H99.7116V226.666H90.9693V230.945H100.454V235.03H85.9373Z"
+ fill="white"
+ />
+ <path
+ d="M59.0617 189.84L58.7406 190.161C57.7243 191.177 57.7243 192.824 58.7406 193.84L67.204 202.301C68.2203 203.317 69.868 203.317 70.8843 202.301L71.2054 201.98C72.2217 200.964 72.2217 199.317 71.2054 198.301L62.742 189.84C61.7257 188.824 60.078 188.824 59.0617 189.84Z"
+ fill="white"
+ />
+ <path
+ d="M80.4113 190.161L80.0902 189.84C79.0739 188.824 77.4261 188.824 76.4098 189.84L67.9464 198.3C66.9301 199.316 66.9301 200.964 67.9464 201.98L68.2675 202.301C69.2839 203.317 70.9316 203.317 71.9479 202.301L80.4113 193.84C81.4276 192.824 81.4276 191.177 80.4113 190.161Z"
+ fill="white"
+ />
+ <path
+ d="M79.951 41.7658V65.0386H76.4283V45.2886H76.2919L70.7237 48.9249V45.5613L76.5305 41.7658H79.951ZM95.716 41.7658V65.0386H92.1933V45.2886H92.0569L86.4887 48.9249V45.5613L92.2955 41.7658H95.716ZM104.936 62.1522C104.314 62.1522 103.78 61.9325 103.333 61.4931C102.886 61.0461 102.663 60.5083 102.663 59.8795C102.663 59.2583 102.886 58.728 103.333 58.2886C103.78 57.8416 104.314 57.6181 104.936 57.6181C105.557 57.6181 106.091 57.8416 106.538 58.2886C106.985 58.728 107.208 59.2583 107.208 59.8795C107.208 60.2961 107.102 60.6787 106.89 61.0272C106.686 61.3681 106.413 61.6408 106.072 61.8454C105.731 62.0499 105.352 62.1522 104.936 62.1522ZM104.936 50.4931C104.314 50.4931 103.78 50.2734 103.333 49.834C102.886 49.3871 102.663 48.8492 102.663 48.2204C102.663 47.5992 102.886 47.0689 103.333 46.6295C103.78 46.1825 104.314 45.959 104.936 45.959C105.557 45.959 106.091 46.1825 106.538 46.6295C106.985 47.0689 107.208 47.5992 107.208 48.2204C107.208 48.6371 107.102 49.0196 106.89 49.3681C106.686 49.709 106.413 49.9818 106.072 50.1863C105.731 50.3908 105.352 50.4931 104.936 50.4931ZM120.036 65.3568C118.612 65.3568 117.331 65.084 116.195 64.5386C115.066 63.9855 114.165 63.228 113.49 62.2658C112.816 61.3037 112.456 60.2052 112.411 58.9704H115.82C115.903 59.9704 116.346 60.7924 117.149 61.4363C117.952 62.0802 118.915 62.4022 120.036 62.4022C120.93 62.4022 121.721 62.1977 122.411 61.7886C123.108 61.3719 123.653 60.7999 124.047 60.0727C124.449 59.3454 124.649 58.5158 124.649 57.584C124.649 56.6371 124.445 55.7924 124.036 55.0499C123.627 54.3075 123.062 53.7242 122.343 53.2999C121.63 52.8757 120.812 52.6598 119.888 52.6522C119.183 52.6522 118.475 52.7734 117.763 53.0158C117.051 53.2583 116.475 53.5765 116.036 53.9704L112.82 53.4931L114.127 41.7658H126.922V44.7772H117.047L116.308 51.2886H116.445C116.899 50.8492 117.502 50.4818 118.252 50.1863C119.009 49.8908 119.82 49.7431 120.683 49.7431C122.1 49.7431 123.362 50.0802 124.468 50.7545C125.581 51.4287 126.456 52.3492 127.093 53.5158C127.737 54.6749 128.055 56.0083 128.047 57.5158C128.055 59.0234 127.714 60.3681 127.024 61.5499C126.343 62.7318 125.396 63.6636 124.183 64.3454C122.979 65.0196 121.596 65.3568 120.036 65.3568ZM132.838 65.0386V62.4931L140.713 54.334C141.554 53.4477 142.247 52.6711 142.792 52.0045C143.345 51.3302 143.758 50.6901 144.031 50.084C144.304 49.478 144.44 48.834 144.44 48.1522C144.44 47.3795 144.258 46.7128 143.895 46.1522C143.531 45.584 143.035 45.1484 142.406 44.8454C141.777 44.5348 141.069 44.3795 140.281 44.3795C139.448 44.3795 138.72 44.5499 138.099 44.8908C137.478 45.2318 137.001 45.7128 136.667 46.334C136.334 46.9552 136.167 47.6825 136.167 48.5158H132.815C132.815 47.0992 133.141 45.8605 133.792 44.7999C134.444 43.7393 135.338 42.9174 136.474 42.334C137.61 41.7431 138.902 41.4477 140.349 41.4477C141.811 41.4477 143.099 41.7393 144.213 42.3227C145.334 42.8984 146.209 43.6863 146.838 44.6863C147.466 45.6787 147.781 46.7999 147.781 48.0499C147.781 48.9136 147.618 49.7583 147.292 50.584C146.974 51.4098 146.417 52.3302 145.622 53.3454C144.826 54.353 143.72 55.5765 142.304 57.0158L137.679 61.8568V62.0272H148.156V65.0386H132.838Z"
+ fill="white"
+ />
+ <rect
+ x="906.516"
+ y="40.8203"
+ width="7.56738"
+ height="24.4583"
+ rx="2"
+ fill="white"
+ />
+ <rect
+ x="895.677"
+ y="46.0703"
+ width="7.56738"
+ height="19.2083"
+ rx="2"
+ fill="white"
+ />
+ <rect
+ x="884.838"
+ y="51.4316"
+ width="7.56738"
+ height="13.8469"
+ rx="2"
+ fill="white"
+ />
+ <rect
+ x="874"
+ y="55.7026"
+ width="7.56738"
+ height="9.57568"
+ rx="2"
+ fill="white"
+ />
+ <path
+ d="M941.078 40.9033C934.711 40.9033 928.344 43.4627 923.774 47.6293C923.516 47.8578 923.508 48.2463 923.75 48.4977L925.836 50.6229C926.078 50.8742 926.485 50.8818 926.742 50.6457C928.563 49.0004 930.641 47.6902 932.938 46.7609C935.516 45.7098 938.258 45.1842 941.086 45.1842C943.914 45.1842 946.656 45.7174 949.235 46.7609C951.531 47.6979 953.61 49.0004 955.43 50.6457C955.688 50.8818 956.094 50.8742 956.336 50.6229L958.422 48.4977C958.664 48.2539 958.656 47.8654 958.399 47.6293C953.813 43.4627 947.445 40.9033 941.078 40.9033Z"
+ fill="white"
+ />
+ <path
+ d="M929.922 54.7285L932.156 56.8842C932.399 57.1127 932.781 57.1279 933.031 56.907C935.242 54.9951 938.078 53.9439 941.07 53.9439C944.063 53.9439 946.899 54.9875 949.11 56.907C949.36 57.1279 949.742 57.1127 949.985 56.8842L952.219 54.7285C952.477 54.4771 952.469 54.0734 952.195 53.8373C949.266 51.2551 945.352 49.6783 941.063 49.6783C936.774 49.6783 932.86 51.2551 929.93 53.8373C929.672 54.0734 929.664 54.4771 929.922 54.7285ZM941.078 58.2857C939.25 58.2857 937.594 59.0322 936.438 60.2281C936.203 60.4719 936.211 60.8451 936.453 61.0812L940.625 65.0955C940.875 65.3393 941.281 65.3393 941.531 65.0955L945.703 61.0812C945.945 60.8451 945.953 60.4719 945.719 60.2281C944.563 59.0398 942.906 58.2857 941.078 58.2857Z"
+ fill="white"
+ />
+ <rect
+ x="968.492"
+ y="38.5908"
+ width="51.4128"
+ height="29"
+ rx="8"
+ fill="#34C759"
+ />
+ <path
+ d="M980.991 44.1363V61.5908H977.301V47.6391H977.198L973.201 50.1448V46.8721L977.522 44.1363H980.991ZM991.743 61.9743C990.277 61.9687 989.016 61.6079 987.959 60.892C986.908 60.176 986.098 59.1391 985.53 57.7812C984.968 56.4232 984.689 54.7897 984.695 52.8806C984.695 50.9772 984.976 49.355 985.539 48.0141C986.107 46.6732 986.916 45.6533 987.968 44.9545C989.024 44.2499 990.283 43.8976 991.743 43.8976C993.203 43.8976 994.459 44.2499 995.51 44.9545C996.567 45.659 997.379 46.6817 997.948 48.0226C998.516 49.3579 998.797 50.9772 998.791 52.8806C998.791 54.7954 998.507 56.4317 997.939 57.7897C997.377 59.1476 996.57 60.1846 995.519 60.9005C994.468 61.6164 993.209 61.9743 991.743 61.9743ZM991.743 58.9147C992.743 58.9147 993.541 58.4118 994.138 57.4062C994.735 56.4005 995.03 54.892 995.024 52.8806C995.024 51.5567 994.888 50.4545 994.615 49.5738C994.348 48.6931 993.968 48.0312 993.473 47.588C992.985 47.1448 992.408 46.9232 991.743 46.9232C990.749 46.9232 989.953 47.4204 989.357 48.4147C988.76 49.409 988.459 50.8976 988.453 52.8806C988.453 54.2215 988.587 55.3408 988.854 56.2385C989.127 57.1306 989.51 57.801 990.004 58.2499C990.499 58.6931 991.078 58.9147 991.743 58.9147ZM1008.03 61.9743C1006.56 61.9687 1005.3 61.6079 1004.24 60.892C1003.19 60.176 1002.38 59.1391 1001.81 57.7812C1001.25 56.4232 1000.97 54.7897 1000.98 52.8806C1000.98 50.9772 1001.26 49.355 1001.82 48.0141C1002.39 46.6732 1003.2 45.6533 1004.25 44.9545C1005.31 44.2499 1006.57 43.8976 1008.03 43.8976C1009.49 43.8976 1010.74 44.2499 1011.79 44.9545C1012.85 45.659 1013.66 46.6817 1014.23 48.0226C1014.8 49.3579 1015.08 50.9772 1015.07 52.8806C1015.07 54.7954 1014.79 56.4317 1014.22 57.7897C1013.66 59.1476 1012.85 60.1846 1011.8 60.9005C1010.75 61.6164 1009.49 61.9743 1008.03 61.9743ZM1008.03 58.9147C1009.03 58.9147 1009.82 58.4118 1010.42 57.4062C1011.02 56.4005 1011.31 54.892 1011.31 52.8806C1011.31 51.5567 1011.17 50.4545 1010.9 49.5738C1010.63 48.6931 1010.25 48.0312 1009.76 47.588C1009.27 47.1448 1008.69 46.9232 1008.03 46.9232C1007.03 46.9232 1006.24 47.4204 1005.64 48.4147C1005.04 49.409 1004.74 50.8976 1004.74 52.8806C1004.74 54.2215 1004.87 55.3408 1005.14 56.2385C1005.41 57.1306 1005.79 57.801 1006.29 58.2499C1006.78 58.6931 1007.36 58.9147 1008.03 58.9147Z"
+ fill="white"
+ />
+ <path
+ d="M1022.16 48.2734V48.2734C1023.94 48.2734 1025.38 49.7175 1025.38 51.4988V54.6167C1025.38 56.398 1023.94 57.842 1022.16 57.842V57.842V48.2734Z"
+ fill="#34C759"
+ />
+ <defs>
+ <filter
+ id="filter0_d_5_2"
+ x="954.752"
+ y="1684"
+ width="78.1084"
+ height="32.5605"
+ filterUnits="userSpaceOnUse"
+ colorInterpolationFilters="sRGB"
+ >
+ <feFlood floodOpacity="0" result="BackgroundImageFix" />
+ <feColorMatrix
+ in="SourceAlpha"
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
+ result="hardAlpha"
+ />
+ <feOffset dy="1" />
+ <feGaussianBlur stdDeviation="1" />
+ <feComposite in2="hardAlpha" operator="out" />
+ <feColorMatrix
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
+ />
+ <feBlend
+ mode="normal"
+ in2="BackgroundImageFix"
+ result="effect1_dropShadow_5_2"
+ />
+ <feBlend
+ mode="normal"
+ in="SourceGraphic"
+ in2="effect1_dropShadow_5_2"
+ result="shape"
+ />
+ </filter>
+ <filter
+ id="filter1_d_5_2"
+ x="960.448"
+ y="1512"
+ width="68.2227"
+ height="27.1934"
+ filterUnits="userSpaceOnUse"
+ colorInterpolationFilters="sRGB"
+ >
+ <feFlood floodOpacity="0" result="BackgroundImageFix" />
+ <feColorMatrix
+ in="SourceAlpha"
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
+ result="hardAlpha"
+ />
+ <feOffset dy="1" />
+ <feGaussianBlur stdDeviation="1" />
+ <feComposite in2="hardAlpha" operator="out" />
+ <feColorMatrix
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
+ />
+ <feBlend
+ mode="normal"
+ in2="BackgroundImageFix"
+ result="effect1_dropShadow_5_2"
+ />
+ <feBlend
+ mode="normal"
+ in="SourceGraphic"
+ in2="effect1_dropShadow_5_2"
+ result="shape"
+ />
+ </filter>
+ <filter
+ id="filter2_d_5_2"
+ x="954.375"
+ y="1341"
+ width="77.6875"
+ height="32.5605"
+ filterUnits="userSpaceOnUse"
+ colorInterpolationFilters="sRGB"
+ >
+ <feFlood floodOpacity="0" result="BackgroundImageFix" />
+ <feColorMatrix
+ in="SourceAlpha"
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
+ result="hardAlpha"
+ />
+ <feOffset dy="1" />
+ <feGaussianBlur stdDeviation="1" />
+ <feComposite in2="hardAlpha" operator="out" />
+ <feColorMatrix
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
+ />
+ <feBlend
+ mode="normal"
+ in2="BackgroundImageFix"
+ result="effect1_dropShadow_5_2"
+ />
+ <feBlend
+ mode="normal"
+ in="SourceGraphic"
+ in2="effect1_dropShadow_5_2"
+ result="shape"
+ />
+ </filter>
+ <filter
+ id="filter3_d_5_2"
+ x="945.502"
+ y="1170"
+ width="96.333"
+ height="26.353"
+ filterUnits="userSpaceOnUse"
+ colorInterpolationFilters="sRGB"
+ >
+ <feFlood floodOpacity="0" result="BackgroundImageFix" />
+ <feColorMatrix
+ in="SourceAlpha"
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
+ result="hardAlpha"
+ />
+ <feOffset dy="1" />
+ <feGaussianBlur stdDeviation="1" />
+ <feComposite in2="hardAlpha" operator="out" />
+ <feColorMatrix
+ type="matrix"
+ values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
+ />
+ <feBlend
+ mode="normal"
+ in2="BackgroundImageFix"
+ result="effect1_dropShadow_5_2"
+ />
+ <feBlend
+ mode="normal"
+ in="SourceGraphic"
+ in2="effect1_dropShadow_5_2"
+ result="shape"
+ />
+ </filter>
+ </defs>
+ </svg>
+ );
+}
diff --git a/apps/web/src/lib/guides/index.ts b/apps/web/src/lib/guides/index.ts
new file mode 100644
index 00000000..007e6fda
--- /dev/null
+++ b/apps/web/src/lib/guides/index.ts
@@ -0,0 +1,13 @@
+import { GUIDE_REGISTRY } from "./registry";
+import type { GuideDefinition } from "@/lib/guides/types";
+
+export { GUIDE_REGISTRY, isGuideId } from "./registry";
+export type { GuideDefinition, GuideId, GuideRenderProps } from "./registry";
+
+export function getGuideById(guideId: string | null): GuideDefinition | null {
+ if (!guideId) {
+ return null;
+ }
+
+ return GUIDE_REGISTRY.find((guide) => guide.id === guideId) ?? null;
+}
diff --git a/apps/web/src/lib/guides/registry.tsx b/apps/web/src/lib/guides/registry.tsx
new file mode 100644
index 00000000..60dec428
--- /dev/null
+++ b/apps/web/src/lib/guides/registry.tsx
@@ -0,0 +1,28 @@
+import type { GuideDefinition } from "@/lib/guides/types";
+import { gridGuide } from "./definitions/grid";
+// import { customGuide } from "./definitions/custom";
+import {
+ tiktokGuide,
+ igReelsGuide,
+ ytShortsGuide,
+ spotlightGuide,
+} from "./definitions/platforms";
+
+export type { GuideDefinition, GuideRenderProps } from "@/lib/guides/types";
+
+export const GUIDE_REGISTRY = [
+ gridGuide,
+ tiktokGuide,
+ igReelsGuide,
+ ytShortsGuide,
+ spotlightGuide,
+
+ // todo: wire up custom guide fully, then uncomment this:
+ // customGuide,
+] as const satisfies readonly GuideDefinition[];
+
+export type GuideId = (typeof GUIDE_REGISTRY)[number]["id"];
+
+export function isGuideId(value: string): value is GuideId {
+ return GUIDE_REGISTRY.some((guide) => guide.id === value);
+}
diff --git a/apps/web/src/lib/guides/types.ts b/apps/web/src/lib/guides/types.ts
new file mode 100644
index 00000000..340101ac
--- /dev/null
+++ b/apps/web/src/lib/guides/types.ts
@@ -0,0 +1,20 @@
+import type { ReactNode } from "react";
+
+export interface GridConfig {
+ rows: number;
+ cols: number;
+}
+
+export interface GuideRenderProps {
+ width: number;
+ height: number;
+}
+
+export interface GuideDefinition {
+ id: string;
+ label: string;
+ renderPreview: () => ReactNode;
+ renderTriggerIcon: () => ReactNode;
+ renderOverlay: (props: GuideRenderProps) => ReactNode;
+ renderOptions?: () => ReactNode;
+}
diff --git a/apps/web/src/lib/iconify-api.ts b/apps/web/src/lib/iconify-api.ts
deleted file mode 100644
index e4afe111..00000000
--- a/apps/web/src/lib/iconify-api.ts
+++ /dev/null
@@ -1,241 +0,0 @@
-export const ICONIFY_HOSTS = [
- "https://api.iconify.design",
- "https://api.simplesvg.com",
- "https://api.unisvg.com",
-];
-
-let currentHost = ICONIFY_HOSTS[0];
-
-async function fetchWithFallback(path: string): Promise<Response> {
- for (const host of ICONIFY_HOSTS) {
- try {
- const response = await fetch(`${host}${path}`, {
- signal: AbortSignal.timeout(2000),
- });
- if (response.ok) {
- currentHost = host;
- return response;
- }
- } catch (error) {
- console.warn(`Failed to fetch from ${host}:`, error);
- }
- }
- throw new Error("All API hosts failed");
-}
-
-export interface IconSet {
- prefix: string;
- name: string;
- total: number;
- author?: {
- name: string;
- url?: string;
- };
- license?: {
- title: string;
- spdx?: string;
- url?: string;
- };
- samples?: string[];
- category?: string;
- palette?: boolean;
-}
-
-export interface IconSearchResult {
- icons: string[];
- total: number;
- limit: number;
- start: number;
- collections: Record<string, IconSet>;
-}
-
-export interface CollectionInfo {
- prefix: string;
- total: number;
- title?: string;
- uncategorized?: string[];
- categories?: Record<string, string[]>;
- hidden?: string[];
- aliases?: Record<string, string>;
-}
-
-export async function getCollections(
- category?: string,
-): Promise<Record<string, IconSet>> {
- try {
- const response = await fetchWithFallback("/collections?pretty=1");
- const data = (await response.json()) as Record<string, IconSet>;
-
- if (category) {
- const filtered = Object.fromEntries(
- Object.entries(data).filter(
- ([_key, info]) => info.category === category,
- ),
- ) as Record<string, IconSet>;
- return filtered;
- }
-
- return data;
- } catch (error) {
- console.error("Failed to fetch collections:", error);
- return {};
- }
-}
-
-export async function getCollection(
- prefix: string,
-): Promise<CollectionInfo | null> {
- try {
- const response = await fetchWithFallback(
- `/collection?prefix=${prefix}&pretty=1`,
- );
- return await response.json();
- } catch (error) {
- console.error(`Failed to fetch collection ${prefix}:`, error);
- return null;
- }
-}
-
-export async function searchIcons(
- query: string,
- limit: number = 64,
- prefixes?: string[],
- category?: string,
-): Promise<IconSearchResult> {
- const params = new URLSearchParams({
- query,
- limit: limit.toString(),
- pretty: "1",
- });
-
- if (prefixes?.length) {
- params.append("prefixes", prefixes.join(","));
- }
-
- if (category) {
- params.append("category", category);
- }
-
- try {
- const response = await fetchWithFallback(`/search?${params}`);
- return await response.json();
- } catch (error) {
- console.error("Failed to search icons:", error);
- return {
- icons: [],
- total: 0,
- limit,
- start: 0,
- collections: {},
- };
- }
-}
-
-export function buildIconSvgUrl(
- host: string,
- iconName: string,
- params?: {
- color?: string;
- width?: number;
- height?: number;
- flip?: "horizontal" | "vertical" | "horizontal,vertical";
- rotate?: number | string;
- },
-): string {
- const [prefix, name] = iconName.includes(":")
- ? iconName.split(":")
- : ["", iconName];
-
- if (!prefix || !name) {
- throw new Error('Invalid icon name format. Expected "prefix:name"');
- }
-
- const urlParams = new URLSearchParams();
-
- if (params?.color) {
- urlParams.append("color", params.color.replace("#", "%23"));
- }
-
- if (params?.width) {
- urlParams.append("width", params.width.toString());
- }
-
- if (params?.height) {
- urlParams.append("height", params.height.toString());
- }
-
- if (params?.flip) {
- urlParams.append("flip", params.flip);
- }
-
- if (params?.rotate) {
- urlParams.append("rotate", params.rotate.toString());
- }
-
- const queryString = urlParams.toString();
- return `${host}/${prefix}/${name}.svg${queryString ? `?${queryString}` : ""}`;
-}
-
-export function getIconSvgUrl(
- iconName: string,
- params?: Parameters<typeof buildIconSvgUrl>[2],
-): string {
- return buildIconSvgUrl(currentHost, iconName, params);
-}
-
-export async function downloadSvgAsText(
- iconName: string,
- params?: Parameters<typeof getIconSvgUrl>[1],
-): Promise<string> {
- const url = getIconSvgUrl(iconName, params);
- const response = await fetch(url);
- if (!response.ok) {
- throw new Error(`Failed to download SVG: ${response.statusText}`);
- }
- return await response.text();
-}
-
-export function svgToFile(svgText: string, fileName: string): File {
- const blob = new Blob([svgText], { type: "image/svg+xml" });
- return new File([blob], fileName, { type: "image/svg+xml" });
-}
-
-export const POPULAR_COLLECTIONS = {
- general: [
- { prefix: "mdi", name: "Material Design Icons" },
- { prefix: "ic", name: "Google Material Icons" },
- { prefix: "ph", name: "Phosphor" },
- { prefix: "heroicons", name: "Heroicons" },
- { prefix: "lucide", name: "Lucide" },
- { prefix: "tabler", name: "Tabler Icons" },
- { prefix: "fe", name: "Feather Icons" },
- { prefix: "bi", name: "Bootstrap Icons" },
- ],
- brands: [
- { prefix: "simple-icons", name: "Simple Icons" },
- { prefix: "logos", name: "SVG Logos" },
- { prefix: "skill-icons", name: "Skill Icons" },
- { prefix: "devicon", name: "Devicon" },
- { prefix: "fa-brands", name: "Font Awesome Brands" },
- ],
- emoji: [
- { prefix: "noto", name: "Noto Emoji" },
- { prefix: "twemoji", name: "Twemoji" },
- { prefix: "fluent-emoji", name: "Fluent Emoji" },
- { prefix: "fluent-emoji-flat", name: "Fluent Emoji Flat" },
- { prefix: "emojione", name: "EmojiOne" },
- { prefix: "openmoji", name: "OpenMoji" },
- ],
-};
-
-export function getCategoriesFromCollections(
- collections: Record<string, IconSet>,
-): string[] {
- const categories = new Set<string>();
- Object.values(collections).forEach((collection) => {
- if (collection.category) {
- categories.add(collection.category);
- }
- });
- return Array.from(categories).sort();
-}
diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts
new file mode 100644
index 00000000..3ec96344
--- /dev/null
+++ b/apps/web/src/lib/masks/__tests__/snap.test.ts
@@ -0,0 +1,235 @@
+import { describe, expect, test } from "bun:test";
+import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
+import { getMaskSnapGeometry } from "@/lib/masks/geometry";
+import { snapMaskInteraction } from "@/lib/masks/snap";
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
+
+const bounds: ElementBounds = {
+ cx: 200,
+ cy: 150,
+ width: 200,
+ height: 100,
+ rotation: 0,
+};
+
+const canvasSize = {
+ width: 400,
+ height: 300,
+};
+
+const snapThreshold = {
+ x: 8,
+ y: 8,
+};
+
+function buildSplitParams(
+ overrides: Partial<SplitMaskParams> = {},
+): SplitMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ ...overrides,
+ };
+}
+
+function buildRectangleParams(
+ overrides: Partial<RectangleMaskParams> = {},
+): RectangleMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ centerX: 0,
+ centerY: 0,
+ width: 0.4,
+ height: 0.2,
+ rotation: 0,
+ scale: 1,
+ ...overrides,
+ };
+}
+
+function sortSegment(
+ segment: [{ x: number; y: number }, { x: number; y: number }],
+): [{ x: number; y: number }, { x: number; y: number }] {
+ return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
+ { x: number; y: number },
+ { x: number; y: number },
+ ];
+}
+
+describe("mask geometry", () => {
+ test("resolves split mask center from centerX and centerY", () => {
+ expect(
+ getMaskSnapGeometry({
+ params: buildSplitParams({
+ centerX: 0.25,
+ centerY: -0.5,
+ rotation: 45,
+ }),
+ bounds,
+ }),
+ ).toEqual({
+ position: { x: 50, y: -50 },
+ size: { width: 0, height: 0 },
+ rotation: 45,
+ });
+ });
+
+ test("resolves box mask center and size from centerX and centerY", () => {
+ expect(
+ getMaskSnapGeometry({
+ params: buildRectangleParams({
+ centerX: -0.25,
+ centerY: 0.5,
+ width: 0.5,
+ height: 0.6,
+ rotation: 30,
+ }),
+ bounds,
+ }),
+ ).toEqual({
+ position: { x: -50, y: 50 },
+ size: { width: 100, height: 60 },
+ rotation: 30,
+ });
+ });
+
+ test("returns a vertical split stroke segment for rotation 0", () => {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams: buildSplitParams(),
+ width: bounds.width,
+ height: bounds.height,
+ });
+
+ expect(segment).not.toBeNull();
+ if (!segment) {
+ throw new Error("Expected split stroke segment for rotation 0");
+ }
+ expect(sortSegment(segment)).toEqual([
+ { x: bounds.width / 2, y: 0 },
+ { x: bounds.width / 2, y: bounds.height },
+ ]);
+ });
+
+ test("returns a horizontal split stroke segment for rotation 90", () => {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams: buildSplitParams({ rotation: 90 }),
+ width: bounds.width,
+ height: bounds.height,
+ });
+
+ expect(segment).not.toBeNull();
+ if (!segment) {
+ throw new Error("Expected split stroke segment for rotation 90");
+ }
+ expect(sortSegment(segment)).toEqual([
+ { x: 0, y: bounds.height / 2 },
+ { x: bounds.width, y: bounds.height / 2 },
+ ]);
+ });
+});
+
+describe("mask snapping", () => {
+ test("snaps split mask movement using the shared position pipeline", () => {
+ const result = snapMaskInteraction({
+ handleId: "position",
+ startParams: buildSplitParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ }),
+ proposedParams: buildSplitParams({
+ centerX: 0.03,
+ centerY: -0.04,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.centerX).toBe(0);
+ expect(result.params.centerY).toBe(0);
+ expect(result.activeLines).toEqual([
+ { type: "vertical", position: 0 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("snaps box mask movement against element center and edges", () => {
+ const result = snapMaskInteraction({
+ handleId: "position",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ centerX: 0.29,
+ centerY: 0.03,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.centerX).toBeCloseTo(0.3);
+ expect(result.params.centerY).toBe(0);
+ expect(result.activeLines).toEqual([
+ { type: "vertical", position: 100 },
+ { type: "horizontal", position: 0 },
+ ]);
+ });
+
+ test("snaps mask rotation through the shared rotation path", () => {
+ const result = snapMaskInteraction({
+ handleId: "rotation",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ rotation: 88,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.rotation).toBe(90);
+ expect(result.activeLines).toEqual([]);
+ });
+
+ test("snaps edge resize for box masks", () => {
+ const result = snapMaskInteraction({
+ handleId: "right",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ width: 0.98,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.width).toBe(1);
+ expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
+ });
+
+ test("snaps corner resize for box masks", () => {
+ const result = snapMaskInteraction({
+ handleId: "bottom-right",
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({
+ width: 0.99,
+ height: 0.495,
+ }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.width).toBe(1);
+ expect(result.params.height).toBe(0.5);
+ expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
+ });
+});
diff --git a/apps/web/src/lib/masks/definitions/box-like.ts b/apps/web/src/lib/masks/definitions/box-like.ts
new file mode 100644
index 00000000..0436151e
--- /dev/null
+++ b/apps/web/src/lib/masks/definitions/box-like.ts
@@ -0,0 +1,270 @@
+import {
+ DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
+ MIN_MASK_DIMENSION,
+} from "@/constants/mask-constants";
+import { computeFeatherUpdate } from "../param-update";
+import type {
+ BaseMaskParams,
+ MaskDefaultContext,
+ MaskParamUpdateArgs,
+ RectangleMaskParams,
+} from "@/lib/masks/types";
+import type {
+ NumberParamDefinition,
+ ParamDefinition,
+ ParamValues,
+} from "@/lib/params";
+
+const PERCENTAGE_DISPLAY: Pick<
+ NumberParamDefinition,
+ "displayMultiplier" | "step"
+> = {
+ displayMultiplier: 100,
+ step: 1,
+};
+
+export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
+ keyof RectangleMaskParams & string
+>[] = [
+ {
+ key: "centerX",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "centerY",
+ label: "Y",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "width",
+ label: "Width",
+ type: "number",
+ default: 0.6,
+ min: 1,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "height",
+ label: "Height",
+ type: "number",
+ default: 0.6,
+ min: 1,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ {
+ key: "scale",
+ label: "Scale",
+ type: "number",
+ default: 1,
+ min: 1,
+ max: 500,
+ ...PERCENTAGE_DISPLAY,
+ },
+ {
+ key: "strokeAlign",
+ label: "Stroke Align",
+ type: "select",
+ default: "center",
+ options: [
+ { value: "inside", label: "Inside" },
+ { value: "center", label: "Center" },
+ { value: "outside", label: "Outside" },
+ ],
+ },
+];
+
+export function getDefaultBaseMaskParams(): BaseMaskParams {
+ return {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ };
+}
+
+export function getStrokeOffset({
+ strokeAlign,
+ strokeWidth,
+}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
+ if (strokeAlign === "inside") {
+ return -(strokeWidth / 2);
+ }
+
+ if (strokeAlign === "outside") {
+ return strokeWidth / 2;
+ }
+
+ return 0;
+}
+
+export function getDefaultSquareMaskParams({
+ elementSize,
+}: MaskDefaultContext): RectangleMaskParams {
+ const absWidth = Math.abs(elementSize?.width ?? 0);
+ const absHeight = Math.abs(elementSize?.height ?? 0);
+ const shortSide = Math.min(absWidth, absHeight);
+ const squareSide =
+ shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
+ const width =
+ absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
+ const height =
+ absHeight > 0
+ ? squareSide / absHeight
+ : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
+
+ return {
+ ...getDefaultBaseMaskParams(),
+ centerX: 0,
+ centerY: 0,
+ width,
+ height,
+ rotation: 0,
+ scale: 1,
+ };
+}
+
+export function getBoxLikeGeometry({
+ params,
+ width,
+ height,
+}: {
+ params: RectangleMaskParams;
+ width: number;
+ height: number;
+}) {
+ return {
+ centerX: width / 2 + params.centerX * width,
+ centerY: height / 2 + params.centerY * height,
+ maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width,
+ maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height,
+ rotationRad: (params.rotation * Math.PI) / 180,
+ };
+}
+
+export function computeBoxMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ bounds,
+}: MaskParamUpdateArgs<RectangleMaskParams>): ParamValues {
+ if (handleId === "position") {
+ return {
+ centerX: startParams.centerX + deltaX / bounds.width,
+ centerY: startParams.centerY + deltaY / bounds.height,
+ };
+ }
+
+ if (handleId === "rotation") {
+ const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ const newRotation = (startParams.rotation + currentAngle) % 360;
+ return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
+ }
+
+ if (handleId === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.sin(angleRad),
+ directionY: Math.cos(angleRad),
+ });
+ }
+
+ const halfWidth = startParams.width * bounds.width;
+ const halfHeight = startParams.height * bounds.height;
+
+ if (handleId === "right" || handleId === "left") {
+ const sign = handleId === "right" ? 1 : -1;
+ return {
+ width: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.width + (sign * deltaX * 2) / bounds.width,
+ ),
+ };
+ }
+
+ if (handleId === "bottom" || handleId === "top") {
+ const sign = handleId === "bottom" ? 1 : -1;
+ return {
+ height: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.height + (sign * deltaY * 2) / bounds.height,
+ ),
+ };
+ }
+
+ if (
+ handleId === "top-left" ||
+ handleId === "top-right" ||
+ handleId === "bottom-left" ||
+ handleId === "bottom-right"
+ ) {
+ const signX = handleId.includes("right") ? 1 : -1;
+ const signY = handleId.includes("bottom") ? 1 : -1;
+ const distance = Math.sqrt(
+ (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
+ );
+ const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
+ const scale = originalDistance > 0 ? distance / originalDistance : 1;
+ return {
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale),
+ height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale),
+ };
+ }
+
+ if (handleId === "scale") {
+ const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
+ const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
+ const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
+ return {
+ scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale),
+ };
+ }
+
+ return {};
+}
+
+export function rotatePoint({
+ x,
+ y,
+ centerX,
+ centerY,
+ rotationRad,
+}: {
+ x: number;
+ y: number;
+ centerX: number;
+ centerY: number;
+ rotationRad: number;
+}) {
+ const dx = x - centerX;
+ const dy = y - centerY;
+ const cos = Math.cos(rotationRad);
+ const sin = Math.sin(rotationRad);
+
+ return {
+ x: centerX + dx * cos - dy * sin,
+ y: centerY + dx * sin + dy * cos,
+ };
+}
diff --git a/apps/web/src/lib/masks/definitions/ellipse.ts b/apps/web/src/lib/masks/definitions/ellipse.ts
new file mode 100644
index 00000000..45d0a7c0
--- /dev/null
+++ b/apps/web/src/lib/masks/definitions/ellipse.ts
@@ -0,0 +1,72 @@
+import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+} from "./box-like";
+
+export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
+ type: "ellipse",
+ name: "Ellipse",
+ overlayShape: "box",
+ buildOverlayPath({ width, height }) {
+ const rx = Math.max((width - 1) / 2, 0);
+ const ry = Math.max((height - 1) / 2, 0);
+ const cx = width / 2;
+ const cy = height / 2;
+ return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
+ },
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "width-height",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ buildDefault(context) {
+ return {
+ type: "ellipse",
+ params: getDefaultSquareMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const path = new Path2D();
+ path.ellipse(
+ centerX,
+ centerY,
+ maskWidth / 2,
+ maskHeight / 2,
+ rotationRad,
+ 0,
+ Math.PI * 2,
+ );
+ return path;
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ const path = new Path2D();
+ path.ellipse(
+ centerX,
+ centerY,
+ Math.max(1, maskWidth / 2 + offset),
+ Math.max(1, maskHeight / 2 + offset),
+ rotationRad,
+ 0,
+ Math.PI * 2,
+ );
+ return path;
+ },
+ },
+};
diff --git a/apps/web/src/lib/masks/definitions/index.ts b/apps/web/src/lib/masks/definitions/index.ts
new file mode 100644
index 00000000..c2affe30
--- /dev/null
+++ b/apps/web/src/lib/masks/definitions/index.ts
@@ -0,0 +1,39 @@
+import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
+import { masksRegistry, type MaskIconProps } from "../registry";
+import { ellipseMaskDefinition } from "./ellipse";
+import { rectangleMaskDefinition } from "./rectangle";
+import { splitMaskDefinition } from "./split";
+import {
+ PanelRightDashedIcon,
+ SquareIcon,
+ CircleIcon,
+} from "@hugeicons/core-free-icons";
+
+function registerDefaultMask<TParams extends BaseMaskParams>({
+ definition,
+ icon,
+}: {
+ definition: MaskDefinition<TParams>;
+ icon: MaskIconProps;
+}) {
+ if (masksRegistry.has(definition.type)) {
+ return;
+ }
+
+ masksRegistry.registerMask({ definition, icon });
+}
+
+export function registerDefaultMasks(): void {
+ registerDefaultMask({
+ definition: splitMaskDefinition,
+ icon: { icon: PanelRightDashedIcon, strokeWidth: 1 },
+ });
+ registerDefaultMask({
+ definition: rectangleMaskDefinition,
+ icon: { icon: SquareIcon },
+ });
+ registerDefaultMask({
+ definition: ellipseMaskDefinition,
+ icon: { icon: CircleIcon },
+ });
+}
diff --git a/apps/web/src/lib/masks/definitions/rectangle.ts b/apps/web/src/lib/masks/definitions/rectangle.ts
new file mode 100644
index 00000000..5b7f84a9
--- /dev/null
+++ b/apps/web/src/lib/masks/definitions/rectangle.ts
@@ -0,0 +1,94 @@
+import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+ rotatePoint,
+} from "./box-like";
+
+function buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth,
+ halfHeight,
+ rotationRad,
+}: {
+ centerX: number;
+ centerY: number;
+ halfWidth: number;
+ halfHeight: number;
+ rotationRad: number;
+}): Path2D {
+ const corners = [
+ { x: centerX - halfWidth, y: centerY - halfHeight },
+ { x: centerX + halfWidth, y: centerY - halfHeight },
+ { x: centerX + halfWidth, y: centerY + halfHeight },
+ { x: centerX - halfWidth, y: centerY + halfHeight },
+ ].map((point) =>
+ rotatePoint({
+ ...point,
+ centerX,
+ centerY,
+ rotationRad,
+ }),
+ );
+
+ const path = new Path2D();
+ path.moveTo(corners[0].x, corners[0].y);
+ for (const corner of corners.slice(1)) {
+ path.lineTo(corner.x, corner.y);
+ }
+ path.closePath();
+ return path;
+}
+
+export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
+ type: "rectangle",
+ name: "Rectangle",
+ overlayShape: "box",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "width-height",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ buildDefault(context) {
+ return {
+ type: "rectangle",
+ params: getDefaultSquareMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams as RectangleMaskParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ return buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(1, maskWidth / 2 + offset),
+ halfHeight: Math.max(1, maskHeight / 2 + offset),
+ rotationRad,
+ });
+ },
+ },
+};
diff --git a/apps/web/src/lib/masks/definitions/split.ts b/apps/web/src/lib/masks/definitions/split.ts
new file mode 100644
index 00000000..43f02db1
--- /dev/null
+++ b/apps/web/src/lib/masks/definitions/split.ts
@@ -0,0 +1,347 @@
+import { computeFeatherUpdate } from "../param-update";
+import type { ParamValues } from "@/lib/params";
+import type {
+ MaskDefinition,
+ MaskParamUpdateArgs,
+ SplitMaskParams,
+} from "@/lib/masks/types";
+import { halfPlaneSign, lineEdgeIntersection } from "../utils";
+
+// cos(Ï€/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
+// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
+// exactly on the split line, which produces spurious midpoint vertices.
+const NORMAL_SNAP_EPSILON = 1e-10;
+
+// Guards against collinear vertices from float noise at canvas edges.
+const MIN_POLYGON_AREA_PX = 0.5;
+const INTERSECTION_EPSILON = 1e-6;
+
+function polygonArea({ vertices }: { vertices: [number, number][] }): number {
+ let area = 0;
+ for (let i = 0; i < vertices.length; i++) {
+ const [x1, y1] = vertices[i];
+ const [x2, y2] = vertices[(i + 1) % vertices.length];
+ area += x1 * y2 - x2 * y1;
+ }
+ return Math.abs(area) * 0.5;
+}
+
+function splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ width: number;
+ height: number;
+}): { normalX: number; normalY: number; lineX: number; lineY: number } {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX =
+ Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
+ const normalY =
+ Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
+ const lineX = width / 2 + centerX * width;
+ const lineY = height / 2 + centerY * height;
+ return { normalX, normalY, lineX, lineY };
+}
+
+function pointsEqual(
+ a: { x: number; y: number },
+ b: { x: number; y: number },
+): boolean {
+ return (
+ Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
+ Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
+ );
+}
+
+export function getSplitMaskStrokeSegment({
+ resolvedParams,
+ width,
+ height,
+}: {
+ resolvedParams: unknown;
+ width: number;
+ height: number;
+}): [{ x: number; y: number }, { x: number; y: number }] | null {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ const edges: [number, number, number, number][] = [
+ [0, 0, width, 0],
+ [width, 0, width, height],
+ [width, height, 0, height],
+ [0, height, 0, 0],
+ ];
+ const intersections: { x: number; y: number }[] = [];
+
+ for (const [x1, y1, x2, y2] of edges) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+
+ if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
+ continue;
+ }
+
+ intersections.push(hit);
+ }
+
+ if (intersections.length !== 2) {
+ return null;
+ }
+
+ return [intersections[0], intersections[1]];
+}
+
+function computeSplitMaskParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ startCanvasX,
+ startCanvasY,
+ bounds,
+ canvasSize,
+}: MaskParamUpdateArgs<SplitMaskParams>): ParamValues {
+ if (handleId === "position") {
+ const rawX = startParams.centerX + deltaX / bounds.width;
+ const rawY = startParams.centerY + deltaY / bounds.height;
+
+ const minX = -bounds.cx / bounds.width;
+ const maxX = (canvasSize.width - bounds.cx) / bounds.width;
+ const minY = -bounds.cy / bounds.height;
+ const maxY = (canvasSize.height - bounds.cy) / bounds.height;
+
+ return {
+ centerX: Math.max(minX, Math.min(maxX, rawX)),
+ centerY: Math.max(minY, Math.min(maxY, rawY)),
+ };
+ }
+
+ if (handleId === "feather") {
+ const angleRad = (startParams.rotation * Math.PI) / 180;
+ return computeFeatherUpdate({
+ startFeather: startParams.feather,
+ deltaX,
+ deltaY,
+ directionX: -Math.cos(angleRad),
+ directionY: -Math.sin(angleRad),
+ });
+ }
+
+ if (handleId === "rotation") {
+ const pivotX = bounds.cx + startParams.centerX * bounds.width;
+ const pivotY = bounds.cy + startParams.centerY * bounds.height;
+ const startAngle =
+ (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
+ Math.PI;
+ const currentAngle =
+ (Math.atan2(
+ startCanvasY + deltaY - pivotY,
+ startCanvasX + deltaX - pivotX,
+ ) *
+ 180) /
+ Math.PI;
+
+ let deltaAngle = currentAngle - startAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+
+ return {
+ rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
+ };
+ }
+
+ return {};
+}
+
+export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
+ type: "split",
+ name: "Split",
+ overlayShape: "line",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "none",
+ },
+ buildDefault() {
+ return {
+ type: "split",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ },
+ };
+ },
+ computeParamUpdate: computeSplitMaskParamUpdate,
+ params: [
+ {
+ key: "centerX",
+ label: "X",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ step: 1,
+ displayMultiplier: 100,
+ },
+ {
+ key: "centerY",
+ label: "Y",
+ type: "number",
+ default: 0,
+ min: -100,
+ max: 100,
+ step: 1,
+ displayMultiplier: 100,
+ },
+ {
+ key: "rotation",
+ label: "Rotation",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 360,
+ step: 1,
+ },
+ ],
+ renderer: {
+ renderMask({ resolvedParams, ctx, width, height, feather }) {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
+ const featherHalf = feather / 2;
+ const gradient = ctx.createLinearGradient(
+ lineX - normalX * featherHalf,
+ lineY - normalY * featherHalf,
+ lineX + normalX * featherHalf,
+ lineY + normalY * featherHalf,
+ );
+ gradient.addColorStop(0, "rgba(255,255,255,0)");
+ gradient.addColorStop(1, "white");
+
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, width, height);
+ },
+
+ buildPath({ resolvedParams, width, height }) {
+ const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
+
+ const edges: [number, number, number, number][] = [
+ [0, 0, width, 0],
+ [width, 0, width, height],
+ [width, height, 0, height],
+ [0, height, 0, 0],
+ ];
+
+ const isInsideHalfPlane = (x: number, y: number) =>
+ halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
+
+ const vertices: [number, number][] = [];
+ for (const [x1, y1, x2, y2] of edges) {
+ const isVertex1Inside = isInsideHalfPlane(x1, y1);
+ const isVertex2Inside = isInsideHalfPlane(x2, y2);
+
+ if (isVertex1Inside && isVertex2Inside) {
+ vertices.push([x2, y2]);
+ } else if (isVertex1Inside && !isVertex2Inside) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+ if (hit) vertices.push([hit.x, hit.y]);
+ } else if (!isVertex1Inside && isVertex2Inside) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+ if (hit) {
+ vertices.push([hit.x, hit.y]);
+ vertices.push([x2, y2]);
+ }
+ }
+ }
+
+ if (
+ vertices.length < 3 ||
+ polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
+ ) {
+ return new Path2D();
+ }
+
+ const path = new Path2D();
+ path.moveTo(vertices[0][0], vertices[0][1]);
+ for (let i = 1; i < vertices.length; i++) {
+ path.lineTo(vertices[i][0], vertices[i][1]);
+ }
+ path.closePath();
+ return path;
+ },
+ buildStrokePath({ resolvedParams, width, height }) {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams,
+ width,
+ height,
+ });
+ const path = new Path2D();
+
+ if (!segment) {
+ return path;
+ }
+
+ path.moveTo(segment[0].x, segment[0].y);
+ path.lineTo(segment[1].x, segment[1].y);
+ return path;
+ },
+ },
+};
diff --git a/apps/web/src/lib/masks/geometry.ts b/apps/web/src/lib/masks/geometry.ts
new file mode 100644
index 00000000..c6d386f8
--- /dev/null
+++ b/apps/web/src/lib/masks/geometry.ts
@@ -0,0 +1,112 @@
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+import { MIN_MASK_DIMENSION } from "@/constants/mask-constants";
+import type { ParamValues } from "@/lib/params";
+import type { RectangleMaskParams } from "@/lib/masks/types";
+
+export function hasCenterParams(params: ParamValues): params is ParamValues & {
+ centerX: number;
+ centerY: number;
+} {
+ return (
+ typeof params.centerX === "number" && typeof params.centerY === "number"
+ );
+}
+
+export function isRectangleMaskParams(
+ params: ParamValues,
+): params is RectangleMaskParams {
+ return (
+ hasCenterParams(params) &&
+ typeof params.width === "number" &&
+ typeof params.height === "number" &&
+ typeof params.rotation === "number" &&
+ typeof params.scale === "number"
+ );
+}
+
+export function getMaskLocalCenter({
+ params,
+ bounds,
+}: {
+ params: ParamValues;
+ bounds: ElementBounds;
+}): { x: number; y: number } | null {
+ if (!hasCenterParams(params)) return null;
+
+ return {
+ x: params.centerX * bounds.width,
+ y: params.centerY * bounds.height,
+ };
+}
+
+export function setMaskLocalCenter({
+ center,
+ bounds,
+}: {
+ center: { x: number; y: number };
+ bounds: ElementBounds;
+}): Pick<ParamValues, "centerX" | "centerY"> {
+ return {
+ centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
+ centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
+ };
+}
+
+export function getMaskSnapGeometry({
+ params,
+ bounds,
+}: {
+ params: ParamValues;
+ bounds: ElementBounds;
+}): {
+ position: { x: number; y: number };
+ size: { width: number; height: number };
+ rotation: number;
+} | null {
+ const position = getMaskLocalCenter({ params, bounds });
+ if (!position) return null;
+
+ if (isRectangleMaskParams(params)) {
+ return {
+ position,
+ size: {
+ width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
+ height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
+ },
+ rotation: params.rotation,
+ };
+ }
+
+ return {
+ position,
+ size: { width: 0, height: 0 },
+ rotation: typeof params.rotation === "number" ? params.rotation : 0,
+ };
+}
+
+export function toGlobalMaskSnapLines({
+ lines,
+ bounds,
+ canvasSize,
+}: {
+ lines: SnapLine[];
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+}): SnapLine[] {
+ const centerX = bounds.cx - canvasSize.width / 2;
+ const centerY = bounds.cy - canvasSize.height / 2;
+
+ return lines.map((line) =>
+ line.type === "vertical"
+ ? {
+ type: "vertical" as const,
+ position: centerX + line.position,
+ }
+ : {
+ type: "horizontal" as const,
+ position: centerY + line.position,
+ },
+ );
+}
+
diff --git a/apps/web/src/lib/masks/handle-positions.ts b/apps/web/src/lib/masks/handle-positions.ts
new file mode 100644
index 00000000..753b17ce
--- /dev/null
+++ b/apps/web/src/lib/masks/handle-positions.ts
@@ -0,0 +1,382 @@
+import { FEATHER_HANDLE_SCALE } from "@/constants/mask-constants";
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type {
+ MaskFeatures,
+ MaskHandlePosition,
+ MaskLinePoints,
+ MaskOverlayShape,
+} from "@/lib/masks/types";
+import type { ParamValues } from "@/lib/params";
+
+const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
+const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
+const LINE_EXTENT_MULTIPLIER = 50;
+
+const CURSOR = {
+ rotate: "cursor-crosshair",
+ resizeDiagonal: "cursor-nwse-resize",
+ resizeHorizontal: "cursor-ew-resize",
+ resizeVertical: "cursor-ns-resize",
+} as const;
+
+function getNumParam({
+ params,
+ key,
+ fallback,
+}: {
+ params: ParamValues;
+ key: string;
+ fallback: number;
+}): number {
+ const value = params[key];
+ return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
+}
+
+/**
+ * The renderer defines the split line as:
+ * - normal direction: (cos(rotation), sin(rotation))
+ * - line direction (parallel to cut): (-sin(rotation), cos(rotation))
+ * - reference point: (centerX * width, centerY * height) from element centre
+ *
+ * So rotation=0 → normal points right → line runs vertically.
+ */
+export function getLineMaskLinePoints({
+ centerX,
+ centerY,
+ rotation,
+ bounds,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ bounds: ElementBounds;
+}): MaskLinePoints {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX = Math.cos(angleRad);
+ const normalY = Math.sin(angleRad);
+ const lineDirX = -normalY;
+ const lineDirY = normalX;
+
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+
+ const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
+
+ return {
+ start: {
+ x: cx - lineDirX * extent,
+ y: cy - lineDirY * extent,
+ },
+ end: {
+ x: cx + lineDirX * extent,
+ y: cy + lineDirY * extent,
+ },
+ };
+}
+
+export function getLineMaskHandlePositions({
+ centerX,
+ centerY,
+ rotation,
+ feather,
+ bounds,
+ displayScale,
+}: {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ feather: number;
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ const angleRad = (rotation * Math.PI) / 180;
+ const normalX = Math.cos(angleRad);
+ const normalY = Math.sin(angleRad);
+
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+
+ const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
+ const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
+
+ return [
+ {
+ id: "rotation",
+ x: cx + normalX * iconOffsetCanvas,
+ y: cy + normalY * iconOffsetCanvas,
+ cursor: CURSOR.rotate,
+ },
+ {
+ id: "feather",
+ x: cx - normalX * featherOffset,
+ y: cy - normalY * featherOffset,
+ cursor: CURSOR.resizeHorizontal,
+ },
+ ];
+}
+
+function rotatePoint({
+ localX,
+ localY,
+ cx,
+ cy,
+ angleRad,
+}: {
+ localX: number;
+ localY: number;
+ cx: number;
+ cy: number;
+ angleRad: number;
+}): { x: number; y: number } {
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ return {
+ x: cx + localX * cos - localY * sin,
+ y: cy + localX * sin + localY * cos,
+ };
+}
+
+export function getBoxMaskHandlePositions({
+ centerX,
+ centerY,
+ width,
+ height,
+ rotation,
+ feather,
+ sizeMode,
+ bounds,
+ displayScale,
+}: {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ feather: number;
+ sizeMode: MaskFeatures["sizeMode"];
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ const cx = bounds.cx + centerX * bounds.width;
+ const cy = bounds.cy + centerY * bounds.height;
+ const angleRad = (rotation * Math.PI) / 180;
+ const halfWidth = (width * bounds.width) / 2;
+ const halfHeight = (height * bounds.height) / 2;
+
+ const handles: MaskHandlePosition[] = [];
+ const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
+
+ const rotHandle = rotatePoint({
+ localX: 0,
+ localY: -halfHeight - handleOffsetCanvas,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "rotation",
+ x: rotHandle.x,
+ y: rotHandle.y,
+ cursor: CURSOR.rotate,
+ });
+
+ const featherHandle = rotatePoint({
+ localX: 0,
+ localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "feather",
+ x: featherHandle.x,
+ y: featherHandle.y,
+ cursor: CURSOR.resizeVertical,
+ });
+
+ if (sizeMode === "width-height") {
+ const corners = [
+ { localX: -halfWidth, localY: -halfHeight, id: "top-left" },
+ { localX: halfWidth, localY: -halfHeight, id: "top-right" },
+ { localX: halfWidth, localY: halfHeight, id: "bottom-right" },
+ { localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
+ ];
+ for (const { localX, localY, id } of corners) {
+ const point = rotatePoint({ localX, localY, cx, cy, angleRad });
+ handles.push({
+ id,
+ x: point.x,
+ y: point.y,
+ cursor: CURSOR.resizeDiagonal,
+ });
+ }
+ const right = rotatePoint({
+ localX: halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const left = rotatePoint({
+ localX: -halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const bottom = rotatePoint({
+ localX: 0,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "left",
+ x: left.x,
+ y: left.y,
+ cursor: CURSOR.resizeHorizontal,
+ });
+ handles.push({
+ id: "right",
+ x: right.x,
+ y: right.y,
+ cursor: CURSOR.resizeHorizontal,
+ });
+ handles.push({
+ id: "bottom",
+ x: bottom.x,
+ y: bottom.y,
+ cursor: CURSOR.resizeVertical,
+ });
+ } else if (sizeMode === "height-only") {
+ const top = rotatePoint({
+ localX: 0,
+ localY: -halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ const bottom = rotatePoint({
+ localX: 0,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "top",
+ x: top.x,
+ y: top.y,
+ cursor: CURSOR.resizeVertical,
+ });
+ handles.push({
+ id: "bottom",
+ x: bottom.x,
+ y: bottom.y,
+ cursor: CURSOR.resizeVertical,
+ });
+ } else if (sizeMode === "width-only") {
+ const left = rotatePoint({
+ localX: -halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ const right = rotatePoint({
+ localX: halfWidth,
+ localY: 0,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "left",
+ x: left.x,
+ y: left.y,
+ cursor: CURSOR.resizeHorizontal,
+ });
+ handles.push({
+ id: "right",
+ x: right.x,
+ y: right.y,
+ cursor: CURSOR.resizeHorizontal,
+ });
+ } else if (sizeMode === "uniform") {
+ const point = rotatePoint({
+ localX: halfWidth,
+ localY: halfHeight,
+ cx,
+ cy,
+ angleRad,
+ });
+ handles.push({
+ id: "scale",
+ x: point.x,
+ y: point.y,
+ cursor: CURSOR.resizeDiagonal,
+ });
+ }
+
+ return handles;
+}
+
+type MaskHandleResolver = (args: {
+ features: MaskFeatures;
+ params: ParamValues;
+ bounds: ElementBounds;
+ displayScale: number;
+}) => MaskHandlePosition[];
+
+const rectangleHandles: MaskHandleResolver = ({
+ features,
+ params,
+ bounds,
+ displayScale,
+}) =>
+ getBoxMaskHandlePositions({
+ centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
+ centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
+ width: getNumParam({ params, key: "width", fallback: 1 }),
+ height: getNumParam({ params, key: "height", fallback: 1 }),
+ rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
+ feather: getNumParam({ params, key: "feather", fallback: 0 }),
+ sizeMode: features.sizeMode,
+ bounds,
+ displayScale,
+ });
+
+const HANDLE_RESOLVERS: Record<MaskOverlayShape, MaskHandleResolver> = {
+ line: ({ params, bounds, displayScale }) =>
+ getLineMaskHandlePositions({
+ centerX: getNumParam({ params, key: "centerX", fallback: 0 }),
+ centerY: getNumParam({ params, key: "centerY", fallback: 0 }),
+ rotation: getNumParam({ params, key: "rotation", fallback: 0 }),
+ feather: getNumParam({ params, key: "feather", fallback: 0 }),
+ bounds,
+ displayScale,
+ }),
+ box: rectangleHandles,
+};
+
+export function getMaskHandlePositions({
+ overlayShape,
+ features,
+ params,
+ bounds,
+ displayScale,
+}: {
+ overlayShape: MaskOverlayShape;
+ features: MaskFeatures;
+ params: ParamValues;
+ bounds: ElementBounds;
+ displayScale: number;
+}): MaskHandlePosition[] {
+ return HANDLE_RESOLVERS[overlayShape]({
+ features,
+ params,
+ bounds,
+ displayScale,
+ });
+}
diff --git a/apps/web/src/lib/masks/index.ts b/apps/web/src/lib/masks/index.ts
new file mode 100644
index 00000000..bc9f20b0
--- /dev/null
+++ b/apps/web/src/lib/masks/index.ts
@@ -0,0 +1,18 @@
+import type { Mask, MaskDefaultContext, MaskType } from "@/lib/masks/types";
+import { masksRegistry } from "./registry";
+import { generateUUID } from "@/utils/id";
+
+export { masksRegistry } from "./registry";
+export { registerDefaultMasks } from "./definitions";
+
+export function buildDefaultMaskInstance({
+ maskType,
+ elementSize,
+}: {
+ maskType: MaskType;
+ elementSize?: { width: number; height: number };
+}): Mask {
+ const definition = masksRegistry.get(maskType);
+ const context: MaskDefaultContext = { elementSize };
+ return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
+}
diff --git a/apps/web/src/lib/masks/param-update.ts b/apps/web/src/lib/masks/param-update.ts
new file mode 100644
index 00000000..967a4be0
--- /dev/null
+++ b/apps/web/src/lib/masks/param-update.ts
@@ -0,0 +1,59 @@
+import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/constants/mask-constants";
+import { masksRegistry } from "@/lib/masks";
+import type { ParamValues } from "@/lib/params";
+import type {
+ BaseMaskParams,
+ MaskParamUpdateArgs,
+ MaskType,
+} from "@/lib/masks/types";
+
+function compactMaskParamValues({
+ params,
+}: {
+ params: Partial<ParamValues>;
+}): ParamValues {
+ const nextParams: ParamValues = {};
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ nextParams[key] = value;
+ }
+ }
+ return nextParams;
+}
+
+export function computeFeatherUpdate({
+ startFeather,
+ deltaX,
+ deltaY,
+ directionX,
+ directionY,
+}: {
+ startFeather: number;
+ deltaX: number;
+ deltaY: number;
+ directionX: number;
+ directionY: number;
+}): ParamValues {
+ const projection = deltaX * directionX + deltaY * directionY;
+ return {
+ feather: Math.max(
+ 0,
+ Math.min(
+ MAX_FEATHER,
+ Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
+ ),
+ ),
+ };
+}
+
+export function computeMaskParamUpdate({
+ maskType,
+ ...args
+}: {
+ maskType: MaskType;
+} & MaskParamUpdateArgs<BaseMaskParams & ParamValues>): ParamValues {
+ const definition = masksRegistry.get(maskType);
+ return compactMaskParamValues({
+ params: definition.computeParamUpdate(args),
+ });
+}
diff --git a/apps/web/src/lib/masks/registry.ts b/apps/web/src/lib/masks/registry.ts
new file mode 100644
index 00000000..5a98cb41
--- /dev/null
+++ b/apps/web/src/lib/masks/registry.ts
@@ -0,0 +1,70 @@
+import { MAX_FEATHER } from "@/constants/mask-constants";
+import type { ParamDefinition } from "@/lib/params";
+import type { BaseMaskParams, MaskDefinition, MaskType } from "@/lib/masks/types";
+import type { HugeiconsIconProps } from "@hugeicons/react";
+import { DefinitionRegistry } from "@/lib/registry";
+
+export type MaskIconProps = {
+ icon: HugeiconsIconProps["icon"];
+ strokeWidth?: number;
+};
+
+const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
+ keyof BaseMaskParams & string
+>[] = [
+ {
+ key: "feather",
+ label: "Feather",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: MAX_FEATHER,
+ step: 1,
+ unit: "percent",
+ },
+ {
+ key: "strokeWidth",
+ label: "Stroke width",
+ type: "number",
+ default: 0,
+ min: 0,
+ max: 100,
+ step: 1,
+ },
+ {
+ key: "strokeColor",
+ label: "Stroke color",
+ type: "color",
+ default: "#ffffff",
+ },
+];
+
+export type RegisteredMaskDefinition = MaskDefinition<BaseMaskParams> & {
+ 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 = {
+ ...definition,
+ params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
+ icon,
+ };
+ this.register(definition.type, withBaseParams);
+ }
+}
+
+export const masksRegistry = new MasksRegistry();
diff --git a/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl b/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl
new file mode 100644
index 00000000..4ba2c5a1
--- /dev/null
+++ b/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl
@@ -0,0 +1,36 @@
+precision mediump float;
+
+uniform sampler2D u_texture;
+uniform sampler2D u_jfa_outside;
+uniform vec2 u_resolution;
+uniform float u_feather_half;
+
+varying vec2 v_texCoord;
+
+vec2 decodeSeed(vec4 encoded) {
+ float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5);
+ float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5);
+ return vec2(x, y);
+}
+
+bool isNoSeed(vec4 encoded) {
+ return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99;
+}
+
+void main() {
+ vec2 pixelCoord = floor(v_texCoord * u_resolution);
+
+ vec4 insideEncoded = texture2D(u_texture, v_texCoord);
+ vec4 outsideEncoded = texture2D(u_jfa_outside, v_texCoord);
+
+ bool hasInside = !isNoSeed(insideEncoded);
+ bool hasOutside = !isNoSeed(outsideEncoded);
+
+ float distToInside = hasInside ? distance(pixelCoord, decodeSeed(insideEncoded)) : 1e5;
+ float distToOutside = hasOutside ? distance(pixelCoord, decodeSeed(outsideEncoded)) : 1e5;
+
+ float signedDist = distToOutside - distToInside;
+ float alpha = smoothstep(-u_feather_half, u_feather_half, signedDist);
+
+ gl_FragColor = vec4(alpha, alpha, alpha, alpha);
+}
diff --git a/apps/web/src/lib/masks/snap.ts b/apps/web/src/lib/masks/snap.ts
new file mode 100644
index 00000000..83cc0e99
--- /dev/null
+++ b/apps/web/src/lib/masks/snap.ts
@@ -0,0 +1,335 @@
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import { MIN_MASK_DIMENSION } from "@/constants/mask-constants";
+import {
+ snapPosition,
+ snapRotation,
+ snapScale,
+ snapScaleAxes,
+ type ScaleEdgePreference,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
+import type { ParamValues } from "@/lib/params";
+import {
+ isRectangleMaskParams,
+ getMaskSnapGeometry,
+ setMaskLocalCenter,
+ toGlobalMaskSnapLines,
+} from "./geometry";
+
+type MaskSnapResult = {
+ params: ParamValues;
+ activeLines: SnapLine[];
+};
+
+const CORNER_SIZE_HANDLES = new Set([
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+]);
+
+function getClampedRatio({
+ next,
+ base,
+}: {
+ next: number;
+ base: number;
+}): number {
+ return (
+ Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
+ );
+}
+
+function getPreferredEdges({
+ handleId,
+}: {
+ handleId: string;
+}): ScaleEdgePreference | undefined {
+ const preferredEdges = {
+ left:
+ handleId === "left" ||
+ handleId === "top-left" ||
+ handleId === "bottom-left",
+ right:
+ handleId === "right" ||
+ handleId === "top-right" ||
+ handleId === "bottom-right",
+ top:
+ handleId === "top" || handleId === "top-left" || handleId === "top-right",
+ bottom:
+ handleId === "bottom" ||
+ handleId === "bottom-left" ||
+ handleId === "bottom-right",
+ } satisfies ScaleEdgePreference;
+
+ return Object.values(preferredEdges).some(Boolean)
+ ? preferredEdges
+ : undefined;
+}
+
+function snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ proposedParams: ParamValues;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult {
+ const geometry = getMaskSnapGeometry({
+ params: proposedParams,
+ bounds,
+ });
+ if (!geometry) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const { snappedPosition, activeLines } = snapPosition({
+ proposedPosition: geometry.position,
+ canvasSize: bounds,
+ elementSize: geometry.size,
+ rotation: geometry.rotation,
+ snapThreshold,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ ...setMaskLocalCenter({
+ center: snappedPosition,
+ bounds,
+ }),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+}
+
+function snapMaskRotation({
+ proposedParams,
+}: {
+ proposedParams: ParamValues;
+}): MaskSnapResult {
+ if (typeof proposedParams.rotation !== "number") {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+}
+
+function snapBoxMaskSize({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ handleId: string;
+ startParams: ParamValues;
+ proposedParams: ParamValues;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult {
+ if (
+ !isRectangleMaskParams(startParams) ||
+ !isRectangleMaskParams(proposedParams)
+ ) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const geometry = getMaskSnapGeometry({
+ params: proposedParams,
+ bounds,
+ });
+ if (!geometry) {
+ return { params: proposedParams, activeLines: [] };
+ }
+
+ const localCanvasSize = bounds;
+ const baseWidth =
+ Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
+ const baseHeight =
+ Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
+ const preferredEdges = getPreferredEdges({ handleId });
+
+ if (handleId === "right" || handleId === "left") {
+ const proposedScaleX = getClampedRatio({
+ next: proposedParams.width,
+ base: startParams.width,
+ });
+ const { x } = snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY: 1,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: x.activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "top" || handleId === "bottom") {
+ const proposedScaleY = getClampedRatio({
+ next: proposedParams.height,
+ base: startParams.height,
+ });
+ const { y } = snapScaleAxes({
+ proposedScaleX: 1,
+ proposedScaleY,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ height: Math.max(
+ MIN_MASK_DIMENSION,
+ startParams.height * y.snappedScale,
+ ),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: y.activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (handleId === "scale") {
+ const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
+ const proposedScale = getClampedRatio({
+ next: proposedParams.scale,
+ base: startParams.scale,
+ });
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale,
+ position: geometry.position,
+ baseWidth: baseWidth * baseScale,
+ baseHeight: baseHeight * baseScale,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ if (CORNER_SIZE_HANDLES.has(handleId)) {
+ const proposedScale = getClampedRatio({
+ next: proposedParams.width,
+ base: startParams.width,
+ });
+ const { snappedScale, activeLines } = snapScale({
+ proposedScale,
+ position: geometry.position,
+ baseWidth,
+ baseHeight,
+ rotation: proposedParams.rotation,
+ canvasSize: localCanvasSize,
+ snapThreshold,
+ preferredEdges,
+ });
+
+ return {
+ params: {
+ ...proposedParams,
+ width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
+ height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
+ },
+ activeLines: toGlobalMaskSnapLines({
+ lines: activeLines,
+ bounds,
+ canvasSize,
+ }),
+ };
+ }
+
+ return { params: proposedParams, activeLines: [] };
+}
+
+export function snapMaskInteraction({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ handleId: string;
+ startParams: ParamValues;
+ proposedParams: ParamValues;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult {
+ if (handleId === "position") {
+ return snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+ }
+
+ if (handleId === "rotation") {
+ return snapMaskRotation({ proposedParams });
+ }
+
+ return snapBoxMaskSize({
+ handleId,
+ startParams,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+}
+
diff --git a/apps/web/src/lib/masks/types.ts b/apps/web/src/lib/masks/types.ts
new file mode 100644
index 00000000..569ff0ff
--- /dev/null
+++ b/apps/web/src/lib/masks/types.ts
@@ -0,0 +1,115 @@
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+
+export type MaskType = "split" | "rectangle" | "ellipse";
+
+export interface BaseMaskParams extends ParamValues {
+ feather: number;
+ inverted: boolean;
+ strokeColor: string;
+ strokeWidth: number;
+ strokeAlign: "inside" | "center" | "outside";
+}
+
+export interface SplitMaskParams extends BaseMaskParams {
+ centerX: number;
+ centerY: number;
+ rotation: number;
+}
+
+export interface RectangleMaskParams extends BaseMaskParams {
+ centerX: number;
+ centerY: number;
+ width: number;
+ height: number;
+ rotation: number;
+ scale: number;
+}
+
+export interface SplitMask {
+ id: string;
+ type: "split";
+ params: SplitMaskParams;
+}
+
+export interface RectangleMask {
+ id: string;
+ type: "rectangle";
+ params: RectangleMaskParams;
+}
+
+export interface EllipseMask {
+ id: string;
+ type: "ellipse";
+ params: RectangleMaskParams;
+}
+
+export type Mask = SplitMask | RectangleMask | EllipseMask;
+
+export interface MaskRenderer {
+ buildPath(params: {
+ resolvedParams: unknown;
+ width: number;
+ height: number;
+ }): Path2D;
+ buildStrokePath?: (params: {
+ resolvedParams: unknown;
+ width: number;
+ height: number;
+ }) => Path2D;
+ /** Renders the feathered mask directly onto ctx, bypassing JFA. */
+ renderMask?: (params: {
+ resolvedParams: unknown;
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ width: number;
+ height: number;
+ feather: number;
+ }) => void;
+}
+
+export type MaskOverlayShape = "line" | "box";
+
+export interface MaskFeatures {
+ hasPosition: boolean;
+ hasRotation: boolean;
+ sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
+}
+
+export interface MaskHandlePosition {
+ id: string;
+ x: number;
+ y: number;
+ cursor: string;
+}
+
+export interface MaskLinePoints {
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+}
+
+export interface MaskDefaultContext {
+ elementSize?: { width: number; height: number };
+}
+
+export interface MaskParamUpdateArgs<TParams extends BaseMaskParams = BaseMaskParams> {
+ handleId: string;
+ startParams: TParams;
+ deltaX: number;
+ deltaY: number;
+ startCanvasX: number;
+ startCanvasY: number;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+}
+
+export interface MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> {
+ type: MaskType;
+ name: string;
+ overlayShape: MaskOverlayShape;
+ features: MaskFeatures;
+ params: ParamDefinition<keyof TParams & string>[];
+ renderer: MaskRenderer;
+ buildOverlayPath?: (params: { width: number; height: number }) => string;
+ buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
+ computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues;
+}
diff --git a/apps/web/src/lib/masks/utils.ts b/apps/web/src/lib/masks/utils.ts
new file mode 100644
index 00000000..a9110237
--- /dev/null
+++ b/apps/web/src/lib/masks/utils.ts
@@ -0,0 +1,64 @@
+const LINE_PARALLEL_EPSILON = 1e-10;
+
+export function lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+}: {
+ lineX: number;
+ lineY: number;
+ normalX: number;
+ normalY: number;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+}): { x: number; y: number } | null {
+ const distance1 = halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x: x1,
+ y: y1,
+ });
+ const distance2 = halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x: x2,
+ y: y2,
+ });
+ const denom = distance1 - distance2;
+ if (Math.abs(denom) < LINE_PARALLEL_EPSILON) return null;
+ const t = distance1 / denom;
+ if (t < 0 || t > 1) return null;
+ return {
+ x: x1 + (x2 - x1) * t,
+ y: y1 + (y2 - y1) * t,
+ };
+}
+
+export function halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x,
+ y,
+}: {
+ lineX: number;
+ lineY: number;
+ normalX: number;
+ normalY: number;
+ x: number;
+ y: number;
+}): number {
+ return (x - lineX) * normalX + (y - lineY) * normalY;
+}
diff --git a/apps/web/src/lib/media/audio-mastering.ts b/apps/web/src/lib/media/audio-mastering.ts
new file mode 100644
index 00000000..29af223b
--- /dev/null
+++ b/apps/web/src/lib/media/audio-mastering.ts
@@ -0,0 +1,103 @@
+const MASTER_LIMITER_THRESHOLD_DB = -1;
+const MASTER_LIMITER_KNEE_DB = 0;
+const MASTER_LIMITER_RATIO = 20;
+const MASTER_LIMITER_ATTACK_SECONDS = 0.001;
+const MASTER_LIMITER_RELEASE_SECONDS = 0.12;
+const MASTER_OUTPUT_HEADROOM = 0.98;
+
+export function getAudioBufferPeak({
+ audioBuffer,
+}: {
+ audioBuffer: AudioBuffer;
+}): number {
+ let peak = 0;
+
+ for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) {
+ const channelData = audioBuffer.getChannelData(channel);
+ for (let index = 0; index < channelData.length; index++) {
+ const magnitude = Math.abs(channelData[index]);
+ if (magnitude > peak) {
+ peak = magnitude;
+ }
+ }
+ }
+
+ return peak;
+}
+
+export function createAudioMasteringChain({
+ audioContext,
+ destination,
+}: {
+ audioContext: AudioContext | OfflineAudioContext;
+ destination: AudioNode;
+}): {
+ input: GainNode;
+} {
+ const input = audioContext.createGain();
+ const limiter = audioContext.createDynamicsCompressor();
+ const outputGain = audioContext.createGain();
+
+ limiter.threshold.value = MASTER_LIMITER_THRESHOLD_DB;
+ limiter.knee.value = MASTER_LIMITER_KNEE_DB;
+ limiter.ratio.value = MASTER_LIMITER_RATIO;
+ limiter.attack.value = MASTER_LIMITER_ATTACK_SECONDS;
+ limiter.release.value = MASTER_LIMITER_RELEASE_SECONDS;
+ outputGain.gain.value = MASTER_OUTPUT_HEADROOM;
+
+ input.connect(limiter);
+ limiter.connect(outputGain);
+ outputGain.connect(destination);
+
+ return { input };
+}
+
+export async function applyAudioMasteringToBuffer({
+ audioBuffer,
+}: {
+ audioBuffer: AudioBuffer;
+}): Promise<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
index e96f158b..41d3b38e 100644
--- a/apps/web/src/lib/media/audio.ts
+++ b/apps/web/src/lib/media/audio.ts
@@ -1,24 +1,44 @@
import type {
AudioElement,
LibraryAudioElement,
- TimelineElement,
+ RetimeConfig,
TimelineTrack,
-} from "@/types/timeline";
-import type { MediaAsset } from "@/types/assets";
+} from "@/lib/timeline";
+import { shouldMaintainPitch } from "@/constants/retime-constants";
+import type { MediaAsset } from "@/lib/media/types";
+import { applyAudioMasteringToBuffer } from "@/lib/media/audio-mastering";
+import type { AudioCapableElement } from "@/lib/timeline/audio-state";
+import {
+ hasAnimatedVolume,
+ resolveEffectiveAudioGain,
+} from "@/lib/timeline/audio-state";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import { canTracktHaveAudio } from "@/lib/timeline";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
+import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
const MAX_AUDIO_CHANNELS = 2;
const EXPORT_SAMPLE_RATE = 44100;
+const COARSE_SAMPLE_COUNT = 2048;
-export type CollectedAudioElement = Omit<
- AudioElement,
- "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
-> & { buffer: AudioBuffer };
+export interface CollectedAudioElement {
+ timelineElement: AudioCapableElement;
+ buffer: AudioBuffer;
+ startTime: number;
+ duration: number;
+ trimStart: number;
+ trimEnd: number;
+ volume: number;
+ muted: boolean;
+ retime?: RetimeConfig;
+}
-export function createAudioContext({ sampleRate }: { sampleRate?: number } = {}): AudioContext {
+export function createAudioContext({
+ sampleRate,
+}: {
+ sampleRate?: number;
+} = {}): AudioContext {
const AudioContextConstructor =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
@@ -90,13 +110,21 @@ export async function collectAudioElements({
audioContext,
}).then((audioBuffer) => {
if (!audioBuffer) return null;
+ const muted = element.muted === true || isTrackMuted;
return {
+ timelineElement: element,
buffer: audioBuffer,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
- muted: element.muted || isTrackMuted,
+ volume: resolveEffectiveAudioGain({
+ element,
+ trackMuted: isTrackMuted,
+ localTime: 0,
+ }),
+ muted,
+ retime: element.retime,
};
}),
);
@@ -113,14 +141,21 @@ export async function collectAudioElements({
audioContext,
}).then((audioBuffer) => {
if (!audioBuffer) return null;
- const elementMuted = element.muted ?? false;
+ const muted = (element.muted ?? false) || isTrackMuted;
return {
+ timelineElement: element,
buffer: audioBuffer,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
- muted: elementMuted || isTrackMuted,
+ volume: resolveEffectiveAudioGain({
+ element,
+ trackMuted: isTrackMuted,
+ localTime: 0,
+ }),
+ muted,
+ retime: element.retime,
};
}),
);
@@ -199,7 +234,10 @@ async function resolveAudioBufferForVideoElement({
if (chunks.length === 0) return null;
const nativeSampleRate = chunks[0].sampleRate;
- const numChannels = Math.min(MAX_AUDIO_CHANNELS, chunks[0].numberOfChannels);
+ const numChannels = Math.min(
+ MAX_AUDIO_CHANNELS,
+ chunks[0].numberOfChannels,
+ );
const nativeChannels = Array.from(
{ length: numChannels },
@@ -208,17 +246,29 @@ async function resolveAudioBufferForVideoElement({
let offset = 0;
for (const chunk of chunks) {
for (let channel = 0; channel < numChannels; channel++) {
- const sourceData = chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1));
+ const sourceData = chunk.getChannelData(
+ Math.min(channel, chunk.numberOfChannels - 1),
+ );
nativeChannels[channel].set(sourceData, offset);
}
offset += chunk.length;
}
// use OfflineAudioContext for high-quality resampling to target rate
- const outputSamples = Math.ceil(totalSamples * (targetSampleRate / nativeSampleRate));
- const offlineContext = new OfflineAudioContext(numChannels, outputSamples, targetSampleRate);
+ const outputSamples = Math.ceil(
+ totalSamples * (targetSampleRate / nativeSampleRate),
+ );
+ const offlineContext = new OfflineAudioContext(
+ numChannels,
+ outputSamples,
+ targetSampleRate,
+ );
- const nativeBuffer = audioContext.createBuffer(numChannels, totalSamples, nativeSampleRate);
+ const nativeBuffer = audioContext.createBuffer(
+ numChannels,
+ totalSamples,
+ nativeSampleRate,
+ );
for (let ch = 0; ch < numChannels; ch++) {
nativeBuffer.copyToChannel(nativeChannels[ch], ch);
}
@@ -238,14 +288,18 @@ async function resolveAudioBufferForVideoElement({
}
interface AudioMixSource {
+ timelineElement: AudioCapableElement;
file: File;
startTime: number;
duration: number;
trimStart: number;
trimEnd: number;
+ volume: number;
+ retime?: RetimeConfig;
}
export interface AudioClipSource {
+ timelineElement: AudioCapableElement;
id: string;
sourceKey: string;
file: File;
@@ -253,13 +307,17 @@ export interface AudioClipSource {
duration: number;
trimStart: number;
trimEnd: number;
+ volume: number;
muted: boolean;
+ retime?: RetimeConfig;
}
async function fetchLibraryAudioSource({
element,
+ volume,
}: {
element: LibraryAudioElement;
+ volume: number;
}): Promise<AudioMixSource | null> {
try {
const response = await fetch(element.sourceUrl);
@@ -273,11 +331,14 @@ async function fetchLibraryAudioSource({
});
return {
+ timelineElement: element,
file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
+ volume,
+ retime: element.retime,
};
} catch (error) {
console.warn("Failed to fetch library audio:", error);
@@ -288,9 +349,11 @@ async function fetchLibraryAudioSource({
async function fetchLibraryAudioClip({
element,
muted,
+ volume,
}: {
element: LibraryAudioElement;
muted: boolean;
+ volume: number;
}): Promise<AudioClipSource | null> {
try {
const response = await fetch(element.sourceUrl);
@@ -304,6 +367,7 @@ async function fetchLibraryAudioClip({
});
return {
+ timelineElement: element,
id: element.id,
sourceKey: element.id,
file,
@@ -311,7 +375,9 @@ async function fetchLibraryAudioClip({
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
+ volume,
muted,
+ retime: element.retime,
};
} catch (error) {
console.warn("Failed to fetch library audio:", error);
@@ -322,16 +388,21 @@ async function fetchLibraryAudioClip({
function collectMediaAudioSource({
element,
mediaAsset,
+ volume,
}: {
- element: TimelineElement;
+ element: AudioCapableElement;
mediaAsset: MediaAsset;
+ volume: number;
}): AudioMixSource {
return {
+ timelineElement: element,
file: mediaAsset.file,
startTime: element.startTime,
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
+ volume,
+ retime: element.retime,
};
}
@@ -339,12 +410,15 @@ function collectMediaAudioClip({
element,
mediaAsset,
muted,
+ volume,
}: {
- element: TimelineElement;
+ element: AudioCapableElement;
mediaAsset: MediaAsset;
muted: boolean;
+ volume: number;
}): AudioClipSource {
return {
+ timelineElement: element,
id: element.id,
sourceKey: mediaAsset.id,
file: mediaAsset.file,
@@ -352,7 +426,9 @@ function collectMediaAudioClip({
duration: element.duration,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
+ volume,
muted,
+ retime: element.retime,
};
}
@@ -374,6 +450,11 @@ export async function collectAudioMixSources({
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
+ if (element.muted === true) continue;
+ const volume = resolveEffectiveAudioGain({
+ element,
+ localTime: 0,
+ });
if (element.type === "audio") {
if (element.sourceType === "upload") {
@@ -381,10 +462,12 @@ export async function collectAudioMixSources({
if (!mediaAsset) continue;
audioMixSources.push(
- collectMediaAudioSource({ element, mediaAsset }),
+ collectMediaAudioSource({ element, mediaAsset, volume }),
);
} else {
- pendingLibrarySources.push(fetchLibraryAudioSource({ element }));
+ pendingLibrarySources.push(
+ fetchLibraryAudioSource({ element, volume }),
+ );
}
continue;
}
@@ -395,7 +478,7 @@ export async function collectAudioMixSources({
if (mediaSupportsAudio({ media: mediaAsset })) {
audioMixSources.push(
- collectMediaAudioSource({ element, mediaAsset }),
+ collectMediaAudioSource({ element, mediaAsset, volume }),
);
}
}
@@ -432,6 +515,11 @@ export async function collectAudioClips({
const isElementMuted =
"muted" in element ? (element.muted ?? false) : false;
const muted = isTrackMuted || isElementMuted;
+ const volume = resolveEffectiveAudioGain({
+ element,
+ trackMuted: isTrackMuted,
+ localTime: 0,
+ });
if (element.type === "audio") {
if (element.sourceType === "upload") {
@@ -443,10 +531,13 @@ export async function collectAudioClips({
element,
mediaAsset,
muted,
+ volume,
}),
);
} else {
- pendingLibraryClips.push(fetchLibraryAudioClip({ element, muted }));
+ pendingLibraryClips.push(
+ fetchLibraryAudioClip({ element, muted, volume }),
+ );
}
continue;
}
@@ -461,6 +552,7 @@ export async function collectAudioClips({
element,
mediaAsset,
muted,
+ volume,
}),
);
}
@@ -510,36 +602,114 @@ export async function createTimelineAudioBuffer({
for (const element of audioElements) {
if (element.muted) continue;
+ const renderedBuffer = shouldMaintainPitch({
+ rate: element.retime?.rate ?? 1,
+ maintainPitch: element.retime?.maintainPitch,
+ })
+ ? await renderRetimedBuffer({
+ audioContext: context,
+ sourceBuffer: element.buffer,
+ trimStart: element.trimStart,
+ clipDuration: element.duration,
+ retime: element.retime,
+ maintainPitch: true,
+ })
+ : undefined;
+
mixAudioChannels({
element,
+ buffer: renderedBuffer ?? element.buffer,
+ trimStart: renderedBuffer ? 0 : element.trimStart,
+ retime: renderedBuffer ? undefined : element.retime,
outputBuffer,
outputLength,
sampleRate,
});
}
- return outputBuffer;
+ return await applyAudioMasteringToBuffer({ audioBuffer: outputBuffer });
+}
+
+export function computeGlobalMaxRms({
+ buffer,
+}: {
+ buffer: AudioBuffer;
+}): number {
+ const channels = buffer.numberOfChannels;
+ const step = Math.max(1, Math.floor(buffer.length / COARSE_SAMPLE_COUNT));
+ let globalMax = 0;
+
+ for (let c = 0; c < channels; c++) {
+ const data = buffer.getChannelData(c);
+ for (let i = 0; i + step <= buffer.length; i += step) {
+ for (let j = i; j < i + step; j++) {
+ const abs = Math.abs(data[j]);
+ if (abs > globalMax) globalMax = abs;
+ }
+ }
+ }
+
+ return globalMax || 1;
+}
+
+export function extractRmsRange({
+ buffer,
+ count,
+ startSample,
+ endSample,
+ globalMax,
+}: {
+ buffer: AudioBuffer;
+ count: number;
+ startSample: number;
+ endSample: number;
+ globalMax: number;
+}): number[] {
+ const channels = buffer.numberOfChannels;
+ const rangeLength = endSample - startSample;
+ const step = Math.max(1, Math.floor(rangeLength / count));
+ const peaks = new Float32Array(count);
+
+ for (let c = 0; c < channels; c++) {
+ const data = buffer.getChannelData(c);
+ for (let i = 0; i < count; i++) {
+ const start = startSample + i * step;
+ const end = Math.min(start + step, endSample);
+ for (let j = start; j < end; j++) {
+ const abs = Math.abs(data[j]);
+ if (abs > peaks[i]) peaks[i] = abs;
+ }
+ }
+ }
+
+ const norm = 1 / globalMax;
+ const result = new Array<number>(count);
+ for (let i = 0; i < count; i++) result[i] = Math.min(1, peaks[i] * norm);
+
+ return result;
}
function mixAudioChannels({
element,
+ buffer,
+ trimStart,
+ retime,
outputBuffer,
outputLength,
sampleRate,
}: {
element: CollectedAudioElement;
+ buffer: AudioBuffer;
+ trimStart: number;
+ retime?: RetimeConfig;
outputBuffer: AudioBuffer;
outputLength: number;
sampleRate: number;
}): void {
- const { buffer, startTime, trimStart, duration: elementDuration } = element;
+ const { startTime, duration: elementDuration } = element;
- const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
- const sourceLengthSamples = Math.floor(elementDuration * buffer.sampleRate);
const outputStartSample = Math.floor(startTime * sampleRate);
-
- const resampleRatio = sampleRate / buffer.sampleRate;
- const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
+ const renderedLength = Math.ceil(elementDuration * sampleRate);
const outputChannels = 2;
for (let channel = 0; channel < outputChannels; channel++) {
@@ -547,14 +717,29 @@ function mixAudioChannels({
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
const sourceData = buffer.getChannelData(sourceChannel);
- for (let i = 0; i < resampledLength; i++) {
+ for (let i = 0; i < renderedLength; i++) {
const outputIndex = outputStartSample + i;
if (outputIndex >= outputLength) break;
- const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
+ const clipTime = i / sampleRate;
+ const sourceTime =
+ trimStart + getSourceTimeAtClipTime({ clipTime, retime });
+ const sourceIndex = sourceTime * buffer.sampleRate;
if (sourceIndex >= sourceData.length) break;
- outputData[outputIndex] += sourceData[sourceIndex];
+ const lowerIndex = Math.floor(sourceIndex);
+ const upperIndex = Math.min(sourceData.length - 1, lowerIndex + 1);
+ const fraction = sourceIndex - lowerIndex;
+ const gain = hasAnimatedVolume({ element: element.timelineElement })
+ ? resolveEffectiveAudioGain({
+ element: element.timelineElement,
+ localTime: clipTime,
+ })
+ : element.volume;
+ outputData[outputIndex] +=
+ (sourceData[lowerIndex] * (1 - fraction) +
+ sourceData[upperIndex] * fraction) *
+ gain;
}
}
}
diff --git a/apps/web/src/lib/media/media-utils.ts b/apps/web/src/lib/media/media-utils.ts
index 4b487bf2..52c08f0c 100644
--- a/apps/web/src/lib/media/media-utils.ts
+++ b/apps/web/src/lib/media/media-utils.ts
@@ -1,4 +1,4 @@
-import type { MediaAsset, MediaType } from "@/types/assets";
+import type { MediaAsset, MediaType } from "@/lib/media/types";
export const SUPPORTS_AUDIO: readonly MediaType[] = ["audio", "video"];
diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts
index 11fe9837..c2d1d7f3 100644
--- a/apps/web/src/lib/media/mediabunny.ts
+++ b/apps/web/src/lib/media/mediabunny.ts
@@ -1,7 +1,7 @@
-import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
-import { collectAudioMixSources } from "@/lib/media/audio";
-import type { TimelineTrack } from "@/types/timeline";
-import type { MediaAsset } from "@/types/assets";
+import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
+import { createTimelineAudioBuffer } from "@/lib/media/audio";
+import type { TimelineTrack } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
export async function getVideoInfo({
videoFile,
@@ -12,6 +12,7 @@ export async function getVideoInfo({
width: number;
height: number;
fps: number;
+ hasAudio: boolean;
}> {
const input = new Input({
source: new BlobSource(videoFile),
@@ -27,12 +28,14 @@ export async function getVideoInfo({
const packetStats = await videoTrack.computePacketStats(100);
const fps = packetStats.averagePacketRate;
+ const audioTrack = await input.getPrimaryAudioTrack();
return {
duration,
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
fps,
+ hasAudio: audioTrack !== null,
};
}
@@ -54,12 +57,16 @@ export const extractTimelineAudio = async ({
return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) });
}
- const audioMixSources = await collectAudioMixSources({
+ onProgress?.(10);
+
+ const audioBuffer = await createTimelineAudioBuffer({
tracks,
mediaAssets,
+ duration: totalDuration,
+ sampleRate: SAMPLE_RATE,
});
- if (audioMixSources.length === 0) {
+ if (!audioBuffer) {
const silentDuration = Math.max(1, totalDuration);
const silentSamples = new Float32Array(
Math.ceil(silentDuration * SAMPLE_RATE) * NUM_CHANNELS,
@@ -67,108 +74,33 @@ export const extractTimelineAudio = async ({
return createWavBlob({ samples: silentSamples });
}
- const totalSamples = Math.ceil(totalDuration * SAMPLE_RATE);
- const mixBuffers = [
- new Float32Array(totalSamples),
- new Float32Array(totalSamples),
- ];
-
- for (let i = 0; i < audioMixSources.length; i++) {
- const source = audioMixSources[i];
-
- if (onProgress) {
- onProgress((i / audioMixSources.length) * 90);
- }
-
- try {
- await decodeAndMixAudioSource({
- source,
- mixBuffers,
- totalSamples,
- });
- } catch (error) {
- console.warn(
- `Failed to process audio source ${source.file.name}:`,
- error,
- );
- }
- }
-
- // clamp to prevent clipping
- for (const channel of mixBuffers) {
- for (let i = 0; i < channel.length; i++) {
- channel[i] = Math.max(-1, Math.min(1, channel[i]));
- }
- }
+ onProgress?.(90);
- // interleave channels for wav output
- const interleavedSamples = new Float32Array(totalSamples * NUM_CHANNELS);
- for (let i = 0; i < totalSamples; i++) {
- interleavedSamples[i * 2] = mixBuffers[0][i];
- interleavedSamples[i * 2 + 1] = mixBuffers[1][i];
- }
-
- if (onProgress) {
- onProgress(100);
- }
+ const interleavedSamples = interleaveAudioBuffer({ audioBuffer });
+ onProgress?.(100);
return createWavBlob({ samples: interleavedSamples });
};
-async function decodeAndMixAudioSource({
- source,
- mixBuffers,
- totalSamples,
+function interleaveAudioBuffer({
+ audioBuffer,
}: {
- source: {
- file: File;
- startTime: number;
- duration: number;
- trimStart: number;
- };
- mixBuffers: Float32Array[];
- totalSamples: number;
-}): Promise<void> {
- const input = new Input({
- source: new BlobSource(source.file),
- formats: ALL_FORMATS,
- });
-
- const audioTrack = await input.getPrimaryAudioTrack();
- if (!audioTrack) return;
-
- const sink = new AudioBufferSink(audioTrack);
- const trimEnd = source.trimStart + source.duration;
+ audioBuffer: AudioBuffer;
+}): Float32Array {
+ const numChannels = Math.min(NUM_CHANNELS, audioBuffer.numberOfChannels);
+ const interleavedSamples = new Float32Array(
+ audioBuffer.length * NUM_CHANNELS,
+ );
- for await (const { buffer, timestamp } of sink.buffers(
- source.trimStart,
- trimEnd,
- )) {
- const relativeTime = timestamp - source.trimStart;
- const outputStartSample = Math.floor(
- (source.startTime + relativeTime) * SAMPLE_RATE,
- );
-
- // resample if needed
- const resampleRatio = SAMPLE_RATE / buffer.sampleRate;
-
- for (let ch = 0; ch < NUM_CHANNELS; ch++) {
- const sourceChannel = Math.min(ch, buffer.numberOfChannels - 1);
- const channelData = buffer.getChannelData(sourceChannel);
- const outputChannel = mixBuffers[ch];
-
- const resampledLength = Math.floor(channelData.length * resampleRatio);
- for (let i = 0; i < resampledLength; i++) {
- const outputIdx = outputStartSample + i;
- if (outputIdx < 0 || outputIdx >= totalSamples) continue;
-
- const sourceIdx = Math.floor(i / resampleRatio);
- if (sourceIdx < channelData.length) {
- outputChannel[outputIdx] += channelData[sourceIdx];
- }
- }
+ for (let sampleIndex = 0; sampleIndex < audioBuffer.length; sampleIndex++) {
+ for (let channel = 0; channel < NUM_CHANNELS; channel++) {
+ const sourceChannel = Math.min(channel, Math.max(0, numChannels - 1));
+ interleavedSamples[sampleIndex * NUM_CHANNELS + channel] =
+ audioBuffer.getChannelData(sourceChannel)[sampleIndex] ?? 0;
}
}
+
+ return interleavedSamples;
}
function createWavBlob({ samples }: { samples: Float32Array }): Blob {
diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts
index fbe29acd..547e7ed8 100644
--- a/apps/web/src/lib/media/processing.ts
+++ b/apps/web/src/lib/media/processing.ts
@@ -1,14 +1,34 @@
+import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
import { toast } from "sonner";
-import type { MediaAsset } from "@/types/assets";
import { getMediaTypeFromFile } from "@/lib/media/media-utils";
+import { formatStorageBytes } from "@/services/storage/quota";
+import { storageService } from "@/services/storage/service";
+import type { MediaAsset } from "@/lib/media/types";
import { getVideoInfo } from "./mediabunny";
-import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
export interface ProcessedMediaAsset extends Omit<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,
@@ -63,7 +83,7 @@ const renderToThumbnailDataUrl = ({
return canvas.toDataURL("image/jpeg", 0.8);
};
-export async function generateThumbnail({
+async function generateThumbnail({
videoFile,
timeInSeconds,
}: {
@@ -106,25 +126,29 @@ export async function generateThumbnail({
}
}
-export async function generateImageThumbnail({
+async function generateImageThumbnail({
imageFile,
}: {
imageFile: File;
-}): Promise<string> {
+}): Promise<{ thumbnailUrl: string; width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = new window.Image();
const objectUrl = URL.createObjectURL(imageFile);
image.addEventListener("load", () => {
try {
- const dataUrl = renderToThumbnailDataUrl({
+ const thumbnailUrl = renderToThumbnailDataUrl({
width: image.naturalWidth,
height: image.naturalHeight,
draw: ({ context, width, height }) => {
context.drawImage(image, 0, 0, width, height);
},
});
- resolve(dataUrl);
+ resolve({
+ thumbnailUrl,
+ width: image.naturalWidth,
+ height: image.naturalHeight,
+ });
} catch (error) {
reject(
error instanceof Error ? error : new Error("Could not render image"),
@@ -166,19 +190,34 @@ export async function processMediaAssets({
continue;
}
+ const storageCheck = await storageService.canStoreFile({
+ size: file.size,
+ });
+
+ if (!storageCheck.canStore) {
+ toast.error(`Not enough browser storage for ${file.name}`, {
+ description: getStorageLimitDescription({
+ fileSize: file.size,
+ availableBytes: storageCheck.availableBytes,
+ }),
+ });
+ continue;
+ }
+
const url = URL.createObjectURL(file);
let thumbnailUrl: string | undefined;
let duration: number | undefined;
let width: number | undefined;
let height: number | undefined;
let fps: number | undefined;
+ let hasAudio: boolean | undefined;
try {
if (fileType === "image") {
- const dimensions = await getImageDimensions({ file });
- width = dimensions.width;
- height = dimensions.height;
- thumbnailUrl = await generateImageThumbnail({ imageFile: file });
+ const result = await generateImageThumbnail({ imageFile: file });
+ thumbnailUrl = result.thumbnailUrl;
+ width = result.width;
+ height = result.height;
} else if (fileType === "video") {
try {
const videoInfo = await getVideoInfo({ videoFile: file });
@@ -188,6 +227,7 @@ export async function processMediaAssets({
fps = Number.isFinite(videoInfo.fps)
? Math.round(videoInfo.fps)
: undefined;
+ hasAudio = videoInfo.hasAudio;
thumbnailUrl = await generateThumbnail({
videoFile: file,
@@ -211,6 +251,7 @@ export async function processMediaAssets({
width,
height,
fps,
+ hasAudio,
});
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -230,33 +271,6 @@ export async function processMediaAssets({
return processedAssets;
}
-const getImageDimensions = ({
- file,
-}: {
- file: File;
-}): Promise<{ width: number; height: number }> => {
- return new Promise((resolve, reject) => {
- const img = new window.Image();
- const objectUrl = URL.createObjectURL(file);
-
- img.addEventListener("load", () => {
- const width = img.naturalWidth;
- const height = img.naturalHeight;
- resolve({ width, height });
- URL.revokeObjectURL(objectUrl);
- img.remove();
- });
-
- img.addEventListener("error", () => {
- reject(new Error("Could not load image"));
- URL.revokeObjectURL(objectUrl);
- img.remove();
- });
-
- img.src = objectUrl;
- });
-};
-
const getMediaDuration = ({ file }: { file: File }): Promise<number> => {
return new Promise((resolve, reject) => {
const element = document.createElement(
diff --git a/apps/web/src/types/assets.ts b/apps/web/src/lib/media/types.ts
index c8dddfc7..c8dddfc7 100644
--- a/apps/web/src/types/assets.ts
+++ b/apps/web/src/lib/media/types.ts
diff --git a/apps/web/src/lib/media/upload-toast.ts b/apps/web/src/lib/media/upload-toast.ts
new file mode 100644
index 00000000..e5c5ee28
--- /dev/null
+++ b/apps/web/src/lib/media/upload-toast.ts
@@ -0,0 +1,51 @@
+import { toast } from "sonner";
+
+export interface MediaUploadToastResult {
+ uploadedCount: number;
+ assetNames?: string[];
+}
+
+function getAssetLabel({ count }: { count: number }): string {
+ return count === 1 ? "media asset" : "media assets";
+}
+
+function waitForNextPaint(): Promise<void> {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => resolve());
+ });
+ });
+}
+
+export async function showMediaUploadToast<T extends MediaUploadToastResult>({
+ filesCount,
+ promise,
+}: {
+ filesCount: number;
+ promise: Promise<T> | (() => Promise<T>);
+}) {
+ const run = typeof promise === "function" ? promise : () => promise;
+ const toastPromise = toast.promise(async () => {
+ await waitForNextPaint();
+ return run();
+ }, {
+ loading: `Uploading ${getAssetLabel({ count: filesCount })}...`,
+ success: ({ uploadedCount, assetNames }) => {
+ if (uploadedCount === 1) {
+ const assetName = assetNames?.[0];
+ return assetName
+ ? `${assetName} has been uploaded`
+ : "1 media asset has been uploaded";
+ }
+
+ if (uploadedCount > 1) {
+ return `${uploadedCount} media assets have been uploaded`;
+ }
+
+ return "No media assets were uploaded";
+ },
+ error: `Failed to upload ${getAssetLabel({ count: filesCount })}`,
+ });
+
+ return toastPromise.unwrap();
+}
diff --git a/apps/web/src/lib/params.ts b/apps/web/src/lib/params.ts
new file mode 100644
index 00000000..23085407
--- /dev/null
+++ b/apps/web/src/lib/params.ts
@@ -0,0 +1,50 @@
+export type ParamValues = Record<string, number | string | boolean>;
+
+export type ParamGroup = "stroke";
+
+interface BaseParamDefinition<TKey extends string = string> {
+ key: TKey;
+ label: string;
+ group?: ParamGroup;
+}
+
+export interface NumberParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "number";
+ default: number;
+ min: number;
+ max?: number;
+ step: number;
+ /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
+ displayMultiplier?: number;
+ /** Show as percentage of max. min/max/step/default stay in stored space. */
+ unit?: "percent";
+ /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
+ shortLabel?: string;
+}
+
+export interface BooleanParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "boolean";
+ default: boolean;
+}
+
+export interface ColorParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "color";
+ default: string;
+}
+
+export interface SelectParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "select";
+ default: string;
+ options: Array<{ value: string; label: string }>;
+}
+
+export type ParamDefinition<TKey extends string = string> =
+ | NumberParamDefinition<TKey>
+ | BooleanParamDefinition<TKey>
+ | ColorParamDefinition<TKey>
+ | SelectParamDefinition<TKey>;
+
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index 08ffbfa8..5c416afc 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -1,17 +1,12 @@
-import type { TimelineTrack, TimelineElement } from "@/types/timeline";
-import type { MediaAsset } from "@/types/assets";
+import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { isMainTrack } from "@/lib/timeline";
-import {
- DEFAULT_TEXT_ELEMENT,
- DEFAULT_LINE_HEIGHT,
- DEFAULT_TEXT_BACKGROUND,
- FONT_SIZE_SCALE_REFERENCE,
-} from "@/constants/text-constants";
-import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
+import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
+import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
+import { measureTextElement } from "@/lib/text/measure-element";
import {
getElementLocalTime,
resolveTransformAtTime,
- resolveNumberAtTime,
} from "@/lib/animation";
export interface ElementBounds {
@@ -41,7 +36,8 @@ function getVisualElementBounds({
sourceWidth: number;
sourceHeight: number;
transform: {
- scale: number;
+ scaleX: number;
+ scaleY: number;
position: { x: number; y: number };
rotate: number;
};
@@ -50,8 +46,8 @@ function getVisualElementBounds({
canvasWidth / sourceWidth,
canvasHeight / sourceHeight,
);
- const scaledWidth = sourceWidth * containScale * transform.scale;
- const scaledHeight = sourceHeight * containScale * transform.scale;
+ const scaledWidth = sourceWidth * containScale * transform.scaleX;
+ const scaledHeight = sourceHeight * containScale * transform.scaleY;
const cx = canvasWidth / 2 + transform.position.x;
const cy = canvasHeight / 2 + transform.position.y;
@@ -64,7 +60,53 @@ function getVisualElementBounds({
};
}
-export function getElementBounds({
+function getTransformedRectBounds({
+ canvasWidth,
+ canvasHeight,
+ rect,
+ transform,
+}: {
+ canvasWidth: number;
+ canvasHeight: number;
+ rect: { left: number; top: number; width: number; height: number };
+ transform: {
+ scaleX: number;
+ scaleY: number;
+ position: { x: number; y: number };
+ rotate: number;
+ };
+}): ElementBounds {
+ const localCenterX = rect.left + rect.width / 2;
+ const localCenterY = rect.top + rect.height / 2;
+ const scaledCenterX = localCenterX * transform.scaleX;
+ const scaledCenterY = localCenterY * transform.scaleY;
+ const rotationRad = (transform.rotate * Math.PI) / 180;
+ const cos = Math.cos(rotationRad);
+ const sin = Math.sin(rotationRad);
+ return {
+ cx:
+ canvasWidth / 2 +
+ transform.position.x +
+ scaledCenterX * cos -
+ scaledCenterY * sin,
+ cy:
+ canvasHeight / 2 +
+ transform.position.y +
+ scaledCenterX * sin +
+ scaledCenterY * cos,
+ width: rect.width * transform.scaleX,
+ height: rect.height * transform.scaleY,
+ rotation: transform.rotate,
+ };
+}
+
+/**
+ * Bounds policy: bounds reflect base content geometry (text glyphs + background,
+ * sticker/image/video content area) and base transform. Post-effect spill (blur,
+ * glow) and mask-clipped regions are intentionally excluded — handles manipulate
+ * the canonical element geometry, not visual effect output.
+ */
+function getElementBounds({
element,
canvasSize,
mediaAsset,
@@ -106,123 +148,118 @@ export function getElementBounds({
return getVisualElementBounds({
canvasWidth,
canvasHeight,
- sourceWidth: 200,
- sourceHeight: 200,
+ sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
+ sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
transform,
});
}
- if (element.type === "text") {
+ if (element.type === "graphic") {
const transform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
- const scaledFontSize =
- element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
- const letterSpacing = element.letterSpacing ?? 0;
- const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
- const lineHeightPx = scaledFontSize * lineHeight;
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ transform,
+ });
+ }
- let measuredWidth = 100;
- let measuredHeight = scaledFontSize;
+ if (element.type === "text") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
const canvas = document.createElement("canvas");
- canvas.width = 4096;
- canvas.height = 4096;
const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
- if (ctx) {
- const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
- const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
- ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
- ctx.textAlign = element.textAlign as CanvasTextAlign;
- if ("letterSpacing" in ctx) {
- (
- ctx as CanvasRenderingContext2D & { letterSpacing: string }
- ).letterSpacing = `${letterSpacing}px`;
- }
-
- const lines = element.content.split("\n");
- const lineMetrics = lines.map((line) => ctx.measureText(line));
- const block = measureTextBlock({
- lineMetrics,
- lineHeightPx,
- fallbackFontSize: scaledFontSize,
- });
- const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
- const resolvedBackground = {
- ...element.background,
- paddingX: resolveNumberAtTime({
- baseValue:
- element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- }),
- paddingY: resolveNumberAtTime({
- baseValue:
- element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- }),
- offsetX: resolveNumberAtTime({
- baseValue:
- element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- }),
- offsetY: resolveNumberAtTime({
- baseValue:
- element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- }),
- };
- const visualRect = getTextVisualRect({
- textAlign: element.textAlign,
- block,
- background: resolvedBackground,
- fontSizeRatio,
- });
- measuredWidth = visualRect.width;
- measuredHeight = visualRect.height;
- const localCenterX = visualRect.left + visualRect.width / 2;
- const localCenterY = visualRect.top + visualRect.height / 2;
- const scaledCenterX = localCenterX * transform.scale;
- const scaledCenterY = localCenterY * transform.scale;
- const rotationRad = (transform.rotate * Math.PI) / 180;
- const cos = Math.cos(rotationRad);
- const sin = Math.sin(rotationRad);
- const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin;
- const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos;
- return {
- cx: canvasWidth / 2 + transform.position.x + rotatedCenterX,
- cy: canvasHeight / 2 + transform.position.y + rotatedCenterY,
- width: measuredWidth * transform.scale,
- height: measuredHeight * transform.scale,
- rotation: transform.rotate,
- };
- }
+ const measured = measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+ });
- const width = measuredWidth * transform.scale;
- const height = measuredHeight * transform.scale;
- return {
- cx: canvasWidth / 2 + transform.position.x,
- cy: canvasHeight / 2 + transform.position.y,
- width,
- height,
- rotation: transform.rotate,
- };
+ return getTransformedRectBounds({
+ canvasWidth,
+ canvasHeight,
+ rect: measured.visualRect,
+ transform,
+ });
}
return null;
}
+export const ROTATION_HANDLE_OFFSET = 24;
+
+export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+export type Edge = "right" | "left" | "bottom";
+
+export function getCornerPosition({
+ bounds,
+ corner,
+}: {
+ bounds: ElementBounds;
+ corner: Corner;
+}): { x: number; y: number } {
+ const halfW = bounds.width / 2;
+ const halfH = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localX =
+ corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
+ const localY =
+ corner === "top-left" || corner === "top-right" ? -halfH : halfH;
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+export function getEdgeHandlePosition({
+ bounds,
+ edge,
+}: {
+ bounds: ElementBounds;
+ edge: Edge;
+}): { x: number; y: number } {
+ const halfWidth = bounds.width / 2;
+ const halfHeight = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
+ const localY = edge === "bottom" ? halfHeight : 0;
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+export function getRotationHandlePosition({
+ bounds,
+}: {
+ bounds: ElementBounds;
+}): { x: number; y: number } {
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
+ return {
+ x: bounds.cx - localY * sin,
+ y: bounds.cy + localY * cos,
+ };
+}
+
export function getVisibleElementsWithBounds({
tracks,
currentTime,
diff --git a/apps/web/src/lib/preview/hit-test.ts b/apps/web/src/lib/preview/hit-test.ts
index c2fde87b..2552922b 100644
--- a/apps/web/src/lib/preview/hit-test.ts
+++ b/apps/web/src/lib/preview/hit-test.ts
@@ -1,4 +1,5 @@
import type { ElementWithBounds } from "./element-bounds";
+import type { ElementRef } from "@/lib/timeline/types";
function pointInRotatedRect({
px,
@@ -24,14 +25,14 @@ function pointInRotatedRect({
const dy = py - cy;
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
- const halfW = width / 2;
- const halfH = height / 2;
+ const halfW = Math.abs(width) / 2;
+ const halfH = Math.abs(height) / 2;
return (
localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH
);
}
-export function hitTest({
+export function getHitElements({
canvasX,
canvasY,
elementsWithBounds,
@@ -39,7 +40,9 @@ export function hitTest({
canvasX: number;
canvasY: number;
elementsWithBounds: ElementWithBounds[];
-}): ElementWithBounds | null {
+}): ElementWithBounds[] {
+ const hits: ElementWithBounds[] = [];
+
for (let i = elementsWithBounds.length - 1; i >= 0; i--) {
const { bounds } = elementsWithBounds[i];
if (
@@ -53,8 +56,47 @@ export function hitTest({
rotation: bounds.rotation,
})
) {
- return elementsWithBounds[i];
+ hits.push(elementsWithBounds[i]);
}
}
- return null;
+
+ return hits;
+}
+
+export function hitTest({
+ canvasX,
+ canvasY,
+ elementsWithBounds,
+}: {
+ canvasX: number;
+ canvasY: number;
+ elementsWithBounds: ElementWithBounds[];
+}): ElementWithBounds | null {
+ return (
+ getHitElements({
+ canvasX,
+ canvasY,
+ elementsWithBounds,
+ })[0] ?? null
+ );
+}
+
+export function resolvePreferredHit({
+ hits,
+ preferredElements,
+}: {
+ hits: ElementWithBounds[];
+ preferredElements: ElementRef[];
+}): ElementWithBounds | null {
+ if (preferredElements.length === 0) return null;
+
+ return (
+ hits.find((hit) =>
+ preferredElements.some(
+ (preferredElement) =>
+ preferredElement.trackId === hit.trackId &&
+ preferredElement.elementId === hit.elementId,
+ ),
+ ) ?? null
+ );
}
diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts
index dcc87282..64184990 100644
--- a/apps/web/src/lib/preview/preview-coords.ts
+++ b/apps/web/src/lib/preview/preview-coords.ts
@@ -1,90 +1,101 @@
+export interface PreviewViewportGeometry {
+ canvasHeight: number;
+ canvasWidth: number;
+ centerX: number;
+ centerY: number;
+ scale: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}
+
+function getCanvasOrigin({
+ geometry,
+}: {
+ geometry: PreviewViewportGeometry;
+}): { x: number; y: number } {
+ return {
+ x: geometry.viewportWidth / 2 - geometry.centerX * geometry.scale,
+ y: geometry.viewportHeight / 2 - geometry.centerY * geometry.scale,
+ };
+}
+
export function screenToCanvas({
clientX,
clientY,
- canvas,
+ geometry,
+ viewportRect,
}: {
clientX: number;
clientY: number;
- canvas: HTMLCanvasElement;
+ geometry: PreviewViewportGeometry;
+ viewportRect: DOMRect;
}): { x: number; y: number } {
- const rect = canvas.getBoundingClientRect();
- const scaleX = canvas.width / rect.width;
- const scaleY = canvas.height / rect.height;
+ const overlayX = clientX - viewportRect.left;
+ const overlayY = clientY - viewportRect.top;
+
return {
- x: (clientX - rect.left) * scaleX,
- y: (clientY - rect.top) * scaleY,
+ x:
+ geometry.centerX +
+ (overlayX - geometry.viewportWidth / 2) / geometry.scale,
+ y:
+ geometry.centerY +
+ (overlayY - geometry.viewportHeight / 2) / geometry.scale,
};
}
export function canvasToOverlay({
canvasX,
canvasY,
- canvasRect,
- containerRect,
- canvasSize,
+ geometry,
}: {
canvasX: number;
canvasY: number;
- canvasRect: DOMRect;
- containerRect: DOMRect;
- canvasSize: { width: number; height: number };
+ geometry: PreviewViewportGeometry;
}): { x: number; y: number } {
- const scaleX = canvasRect.width / canvasSize.width;
- const scaleY = canvasRect.height / canvasSize.height;
+ const canvasOrigin = getCanvasOrigin({ geometry });
+
return {
- x: canvasRect.left - containerRect.left + canvasX * scaleX,
- y: canvasRect.top - containerRect.top + canvasY * scaleY,
+ x: canvasOrigin.x + canvasX * geometry.scale,
+ y: canvasOrigin.y + canvasY * geometry.scale,
};
}
export function positionToOverlay({
positionX,
positionY,
- canvasRect,
- containerRect,
- canvasSize,
+ geometry,
}: {
positionX: number;
positionY: number;
- canvasRect: DOMRect;
- containerRect: DOMRect;
- canvasSize: { width: number; height: number };
+ geometry: PreviewViewportGeometry;
}): { x: number; y: number } {
- const scaleX = canvasRect.width / canvasSize.width;
- const scaleY = canvasRect.height / canvasSize.height;
- const centerScreenX =
- canvasRect.left - containerRect.left + (canvasSize.width / 2) * scaleX;
- const centerScreenY =
- canvasRect.top - containerRect.top + (canvasSize.height / 2) * scaleY;
- return {
- x: centerScreenX + positionX * scaleX,
- y: centerScreenY + positionY * scaleY,
- };
+ return canvasToOverlay({
+ canvasX: geometry.canvasWidth / 2 + positionX,
+ canvasY: geometry.canvasHeight / 2 + positionY,
+ geometry,
+ });
}
export function getDisplayScale({
- canvasRect,
- canvasSize,
+ geometry,
}: {
- canvasRect: DOMRect;
- canvasSize: { width: number; height: number };
+ geometry: PreviewViewportGeometry;
}): { x: number; y: number } {
return {
- x: canvasRect.width / canvasSize.width,
- y: canvasRect.height / canvasSize.height,
+ x: geometry.scale,
+ y: geometry.scale,
};
}
export function screenPixelsToLogicalThreshold({
- canvas,
+ geometry,
screenPixels,
}: {
- canvas: HTMLCanvasElement;
+ geometry: PreviewViewportGeometry;
screenPixels: number;
}): { x: number; y: number } {
- const canvasRect = canvas.getBoundingClientRect();
return {
- x: screenPixels * (canvas.width / canvasRect.width),
- y: screenPixels * (canvas.height / canvasRect.height),
+ x: screenPixels / geometry.scale,
+ y: screenPixels / geometry.scale,
};
}
diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts
index 36efc3ce..64220264 100644
--- a/apps/web/src/lib/preview/preview-snap.ts
+++ b/apps/web/src/lib/preview/preview-snap.ts
@@ -13,15 +13,70 @@ export interface SnapResult {
activeLines: SnapLine[];
}
+type ScaleEdge = "left" | "right" | "top" | "bottom";
+
+export interface ScaleEdgePreference {
+ left?: boolean;
+ right?: boolean;
+ top?: boolean;
+ bottom?: boolean;
+}
+
+function hasPreferredEdge({
+ preferredEdges,
+ edge,
+}: {
+ preferredEdges?: ScaleEdgePreference;
+ edge: ScaleEdge;
+}): boolean {
+ return preferredEdges?.[edge] === true;
+}
+
+function pickClosestScaleCandidate<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;
@@ -31,8 +86,11 @@ export function snapPosition({
const top = -canvasSize.height / 2;
const bottom = canvasSize.height / 2;
- const halfWidth = elementSize.width / 2;
- const halfHeight = elementSize.height / 2;
+ const rotRad = (rotation * Math.PI) / 180;
+ const cosR = Math.abs(Math.cos(rotRad));
+ const sinR = Math.abs(Math.sin(rotRad));
+ const halfWidth = (elementSize.width * cosR + elementSize.height * sinR) / 2;
+ const halfHeight = (elementSize.width * sinR + elementSize.height * cosR) / 2;
const activeLines: SnapLine[] = [];
type AxisSnapCandidate = {
@@ -59,8 +117,8 @@ export function snapPosition({
);
}
- const verticalTargets = [left, centerX, right];
- const horizontalTargets = [top, centerY, bottom];
+ const verticalTargets = [centerX, left, right];
+ const horizontalTargets = [centerY, top, bottom];
const xCandidates: AxisSnapCandidate[] = [];
for (const targetX of verticalTargets) {
@@ -133,15 +191,19 @@ export function snapScale({
position,
baseWidth,
baseHeight,
+ rotation = 0,
canvasSize,
snapThreshold,
+ preferredEdges,
}: {
proposedScale: number;
position: { x: number; y: number };
baseWidth: number;
baseHeight: number;
+ rotation?: number;
canvasSize: { width: number; height: number };
snapThreshold: { x: number; y: number };
+ preferredEdges?: ScaleEdgePreference;
}): ScaleSnapResult {
const centerX = 0;
const centerY = 0;
@@ -150,15 +212,22 @@ export function snapScale({
const top = -canvasSize.height / 2;
const bottom = canvasSize.height / 2;
- const leftEdge = position.x - (baseWidth * proposedScale) / 2;
- const rightEdge = position.x + (baseWidth * proposedScale) / 2;
- const topEdge = position.y - (baseHeight * proposedScale) / 2;
- const bottomEdge = position.y + (baseHeight * proposedScale) / 2;
+ const rotRad = (rotation * Math.PI) / 180;
+ const cosR = Math.abs(Math.cos(rotRad));
+ const sinR = Math.abs(Math.sin(rotRad));
+ const aabbBaseHalfW = (baseWidth * cosR + baseHeight * sinR) / 2;
+ const aabbBaseHalfH = (baseWidth * sinR + baseHeight * cosR) / 2;
+
+ const leftEdge = position.x - aabbBaseHalfW * proposedScale;
+ const rightEdge = position.x + aabbBaseHalfW * proposedScale;
+ const topEdge = position.y - aabbBaseHalfH * proposedScale;
+ const bottomEdge = position.y + aabbBaseHalfH * proposedScale;
interface SnapCandidate {
scale: number;
distance: number;
lines: SnapLine[];
+ edge: ScaleEdge;
}
const candidates: SnapCandidate[] = [];
@@ -175,23 +244,25 @@ export function snapScale({
for (const target of verticalTargets) {
const distanceLeft = Math.abs(leftEdge - target.position);
if (distanceLeft <= snapThreshold.x) {
- const scale = (2 * (position.x - target.position)) / baseWidth;
- if (scale > MIN_SCALE) {
+ const scale = (position.x - target.position) / aabbBaseHalfW;
+ if (Math.abs(scale) > MIN_SCALE) {
candidates.push({
scale,
distance: distanceLeft,
lines: [target.line],
+ edge: "left",
});
}
}
const distanceRight = Math.abs(rightEdge - target.position);
if (distanceRight <= snapThreshold.x) {
- const scale = (2 * (target.position - position.x)) / baseWidth;
- if (scale > MIN_SCALE) {
+ const scale = (target.position - position.x) / aabbBaseHalfW;
+ if (Math.abs(scale) > MIN_SCALE) {
candidates.push({
scale,
distance: distanceRight,
lines: [target.line],
+ edge: "right",
});
}
}
@@ -212,40 +283,42 @@ export function snapScale({
for (const target of horizontalTargets) {
const distanceTop = Math.abs(topEdge - target.position);
if (distanceTop <= snapThreshold.y) {
- const scale = (2 * (position.y - target.position)) / baseHeight;
- if (scale > MIN_SCALE) {
+ const scale = (position.y - target.position) / aabbBaseHalfH;
+ if (Math.abs(scale) > MIN_SCALE) {
candidates.push({
scale,
distance: distanceTop,
lines: [target.line],
+ edge: "top",
});
}
}
const distanceBottom = Math.abs(bottomEdge - target.position);
if (distanceBottom <= snapThreshold.y) {
- const scale = (2 * (target.position - position.y)) / baseHeight;
- if (scale > MIN_SCALE) {
+ const scale = (target.position - position.y) / aabbBaseHalfH;
+ if (Math.abs(scale) > MIN_SCALE) {
candidates.push({
scale,
distance: distanceBottom,
lines: [target.line],
+ edge: "bottom",
});
}
}
}
- if (candidates.length === 0) {
+ const best = pickClosestScaleCandidate({
+ candidates,
+ preferredEdges,
+ });
+ if (!best) {
return { snappedScale: proposedScale, activeLines: [] };
}
- const best = candidates.reduce((acc, candidate) =>
- candidate.distance < acc.distance ? candidate : acc,
- );
-
- const snappedLeft = position.x - (baseWidth * best.scale) / 2;
- const snappedRight = position.x + (baseWidth * best.scale) / 2;
- const snappedTop = position.y - (baseHeight * best.scale) / 2;
- const snappedBottom = position.y + (baseHeight * best.scale) / 2;
+ const snappedLeft = position.x - aabbBaseHalfW * best.scale;
+ const snappedRight = position.x + aabbBaseHalfW * best.scale;
+ const snappedTop = position.y - aabbBaseHalfH * best.scale;
+ const snappedBottom = position.y + aabbBaseHalfH * best.scale;
const activeLines: SnapLine[] = [];
const seenKeys = new Set<string>();
@@ -260,16 +333,26 @@ export function snapScale({
for (const target of verticalTargets) {
if (
- Math.abs(snappedLeft - target.position) <= 1 ||
- Math.abs(snappedRight - target.position) <= 1
+ (hasPreferredEdge({ preferredEdges, edge: "left" }) &&
+ Math.abs(snappedLeft - target.position) <= 1) ||
+ (hasPreferredEdge({ preferredEdges, edge: "right" }) &&
+ Math.abs(snappedRight - target.position) <= 1) ||
+ (!preferredEdges &&
+ (Math.abs(snappedLeft - target.position) <= 1 ||
+ Math.abs(snappedRight - target.position) <= 1))
) {
addLine({ line: target.line });
}
}
for (const target of horizontalTargets) {
if (
- Math.abs(snappedTop - target.position) <= 1 ||
- Math.abs(snappedBottom - target.position) <= 1
+ (hasPreferredEdge({ preferredEdges, edge: "top" }) &&
+ Math.abs(snappedTop - target.position) <= 1) ||
+ (hasPreferredEdge({ preferredEdges, edge: "bottom" }) &&
+ Math.abs(snappedBottom - target.position) <= 1) ||
+ (!preferredEdges &&
+ (Math.abs(snappedTop - target.position) <= 1 ||
+ Math.abs(snappedBottom - target.position) <= 1))
) {
addLine({ line: target.line });
}
@@ -281,6 +364,158 @@ export function snapScale({
};
}
+export interface AxisSnapResult {
+ snappedScale: number;
+ /** Infinity when no snap candidate was within threshold */
+ snapDistance: number;
+ activeLines: SnapLine[];
+}
+
+export function snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY,
+ position,
+ baseWidth,
+ baseHeight,
+ rotation = 0,
+ canvasSize,
+ snapThreshold,
+ preferredEdges,
+}: {
+ proposedScaleX: number;
+ proposedScaleY: number;
+ position: { x: number; y: number };
+ baseWidth: number;
+ baseHeight: number;
+ rotation?: number;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+ preferredEdges?: ScaleEdgePreference;
+}): { x: AxisSnapResult; y: AxisSnapResult } {
+ const canvasLeft = -canvasSize.width / 2;
+ const canvasRight = canvasSize.width / 2;
+ const canvasTop = -canvasSize.height / 2;
+ const canvasBottom = canvasSize.height / 2;
+
+ const rotRad = (rotation * Math.PI) / 180;
+ const cosR = Math.abs(Math.cos(rotRad));
+ const sinR = Math.abs(Math.sin(rotRad));
+ const EPSILON = 1e-6;
+
+ // Current AABB edges at proposed scales
+ const currentAabbHalfW = (baseWidth * proposedScaleX * cosR + baseHeight * proposedScaleY * sinR) / 2;
+ const currentAabbHalfH = (baseWidth * proposedScaleX * sinR + baseHeight * proposedScaleY * cosR) / 2;
+ const currentLeftEdge = position.x - currentAabbHalfW;
+ const currentRightEdge = position.x + currentAabbHalfW;
+ const currentTopEdge = position.y - currentAabbHalfH;
+ const currentBottomEdge = position.y + currentAabbHalfH;
+
+ interface Candidate {
+ scale: number;
+ distance: number;
+ line: SnapLine;
+ edge: ScaleEdge;
+ }
+
+ function bestCandidate({
+ candidates,
+ proposedScale,
+ }: {
+ candidates: Candidate[];
+ proposedScale: number;
+ }): AxisSnapResult {
+ const best = pickClosestScaleCandidate({
+ candidates,
+ preferredEdges,
+ });
+ if (!best) {
+ return { snappedScale: proposedScale, snapDistance: Infinity, activeLines: [] };
+ }
+ return { snappedScale: best.scale, snapDistance: best.distance, activeLines: [best.line] };
+ }
+
+ // sX candidates: snap via vertical targets (left/right AABB edges) — only valid when cosR ≠ 0
+ // snap via horizontal targets (top/bottom AABB edges) — only valid when sinR ≠ 0
+ const xCandidates: Candidate[] = [];
+ const yContribW = baseHeight * proposedScaleY * sinR;
+ const yContribH = baseHeight * proposedScaleY * cosR;
+
+ if (cosR > EPSILON) {
+ for (const T of [canvasLeft, 0, canvasRight]) {
+ const line: SnapLine = { type: "vertical", position: T };
+ const distLeft = Math.abs(currentLeftEdge - T);
+ if (distLeft <= snapThreshold.x) {
+ const scale = (2 * (position.x - T) - yContribW) / (baseWidth * cosR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distLeft, line, edge: "left" });
+ }
+ const distRight = Math.abs(currentRightEdge - T);
+ if (distRight <= snapThreshold.x) {
+ const scale = (2 * (T - position.x) - yContribW) / (baseWidth * cosR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distRight, line, edge: "right" });
+ }
+ }
+ }
+
+ if (sinR > EPSILON) {
+ for (const T of [canvasTop, 0, canvasBottom]) {
+ const line: SnapLine = { type: "horizontal", position: T };
+ const distTop = Math.abs(currentTopEdge - T);
+ if (distTop <= snapThreshold.y) {
+ const scale = (2 * (position.y - T) - yContribH) / (baseWidth * sinR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distTop, line, edge: "top" });
+ }
+ const distBottom = Math.abs(currentBottomEdge - T);
+ if (distBottom <= snapThreshold.y) {
+ const scale = (2 * (T - position.y) - yContribH) / (baseWidth * sinR);
+ if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
+ }
+ }
+ }
+
+ // sY candidates: snap via vertical targets — only valid when sinR ≠ 0
+ // snap via horizontal targets — only valid when cosR ≠ 0
+ const yCandidates: Candidate[] = [];
+ const xContribW = baseWidth * proposedScaleX * cosR;
+ const xContribH = baseWidth * proposedScaleX * sinR;
+
+ if (sinR > EPSILON) {
+ for (const T of [canvasLeft, 0, canvasRight]) {
+ const line: SnapLine = { type: "vertical", position: T };
+ const distLeft = Math.abs(currentLeftEdge - T);
+ if (distLeft <= snapThreshold.x) {
+ const scale = (2 * (position.x - T) - xContribW) / (baseHeight * sinR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distLeft, line, edge: "left" });
+ }
+ const distRight = Math.abs(currentRightEdge - T);
+ if (distRight <= snapThreshold.x) {
+ const scale = (2 * (T - position.x) - xContribW) / (baseHeight * sinR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distRight, line, edge: "right" });
+ }
+ }
+ }
+
+ if (cosR > EPSILON) {
+ for (const T of [canvasTop, 0, canvasBottom]) {
+ const line: SnapLine = { type: "horizontal", position: T };
+ const distTop = Math.abs(currentTopEdge - T);
+ if (distTop <= snapThreshold.y) {
+ const scale = (2 * (position.y - T) - xContribH) / (baseHeight * cosR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distTop, line, edge: "top" });
+ }
+ const distBottom = Math.abs(currentBottomEdge - T);
+ if (distBottom <= snapThreshold.y) {
+ const scale = (2 * (T - position.y) - xContribH) / (baseHeight * cosR);
+ if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
+ }
+ }
+ }
+
+ return {
+ x: bestCandidate({ candidates: xCandidates, proposedScale: proposedScaleX }),
+ y: bestCandidate({ candidates: yCandidates, proposedScale: proposedScaleY }),
+ };
+}
+
export interface RotationSnapResult {
snappedRotation: number;
isSnapped: boolean;
diff --git a/apps/web/src/types/project.ts b/apps/web/src/lib/project/types.ts
index c8074c26..734c3835 100644
--- a/apps/web/src/types/project.ts
+++ b/apps/web/src/lib/project/types.ts
@@ -1,4 +1,4 @@
-import type { TScene } from "./timeline";
+import type { TScene } from "@/lib/timeline/types";
export type TBackground =
| {
@@ -27,6 +27,8 @@ export interface TProjectMetadata {
export interface TProjectSettings {
fps: number;
canvasSize: TCanvasSize;
+ canvasSizeMode?: "preset" | "custom";
+ lastCustomCanvasSize?: TCanvasSize | null;
originalCanvasSize?: TCanvasSize | null;
background: TBackground;
}
diff --git a/apps/web/src/lib/registry.ts b/apps/web/src/lib/registry.ts
new file mode 100644
index 00000000..433feae7
--- /dev/null
+++ b/apps/web/src/lib/registry.ts
@@ -0,0 +1,40 @@
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+
+export function buildDefaultParamValues(
+ params: ParamDefinition[],
+): ParamValues {
+ const values: ParamValues = {};
+ for (const param of params) {
+ values[param.key] = param.default;
+ }
+ return values;
+}
+
+export class DefinitionRegistry<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/types/rendering.ts b/apps/web/src/lib/rendering.ts
index 8c9763df..d16296e2 100644
--- a/apps/web/src/types/rendering.ts
+++ b/apps/web/src/lib/rendering.ts
@@ -1,5 +1,6 @@
export interface Transform {
- scale: number;
+ scaleX: number;
+ scaleY: number;
position: {
x: number;
y: number;
diff --git a/apps/web/src/lib/retime/__tests__/resolve.test.ts b/apps/web/src/lib/retime/__tests__/resolve.test.ts
new file mode 100644
index 00000000..4fcf6249
--- /dev/null
+++ b/apps/web/src/lib/retime/__tests__/resolve.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test";
+import {
+ getClipTimeAtSourceTime,
+ getEffectiveRateAt,
+ getSourceTimeAtClipTime,
+ getTimelineDurationForSourceSpan,
+} from "@/lib/retime";
+import type { RetimeConfig } from "@/lib/timeline";
+
+const twoX: RetimeConfig = { rate: 2 };
+const halfX: RetimeConfig = { rate: 0.5 };
+
+describe("retime resolve", () => {
+ test("maps clip time to source time at 2x speed", () => {
+ expect(getSourceTimeAtClipTime({ clipTime: 5, retime: twoX })).toBe(10);
+ });
+
+ test("maps clip time to source time at 0.5x speed", () => {
+ expect(getSourceTimeAtClipTime({ clipTime: 4, retime: halfX })).toBe(2);
+ });
+
+ test("returns clip time unchanged when no retime", () => {
+ expect(getSourceTimeAtClipTime({ clipTime: 7 })).toBe(7);
+ });
+
+ test("inverts source time back to clip time at 2x speed", () => {
+ expect(getClipTimeAtSourceTime({ sourceTime: 10, retime: twoX })).toBe(5);
+ });
+
+ test("returns effective rate", () => {
+ expect(getEffectiveRateAt({ retime: twoX })).toBe(2);
+ expect(getEffectiveRateAt({})).toBe(1);
+ });
+
+ test("derives timeline duration for a visible source span", () => {
+ expect(
+ getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: twoX }),
+ ).toBe(5);
+ expect(
+ getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: halfX }),
+ ).toBe(20);
+ });
+
+ test("clamps invalid rates to 1", () => {
+ expect(getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: 0 } })).toBe(
+ 5,
+ );
+ expect(
+ getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: -1 } }),
+ ).toBe(5);
+ });
+
+ test("caps retime rates above 5x", () => {
+ expect(getSourceTimeAtClipTime({ clipTime: 5, retime: { rate: 100 } })).toBe(
+ 25,
+ );
+ expect(
+ getTimelineDurationForSourceSpan({ sourceSpan: 10, retime: { rate: 100 } }),
+ ).toBe(2);
+ });
+});
diff --git a/apps/web/src/lib/retime/__tests__/split.test.ts b/apps/web/src/lib/retime/__tests__/split.test.ts
new file mode 100644
index 00000000..0ce532a1
--- /dev/null
+++ b/apps/web/src/lib/retime/__tests__/split.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from "bun:test";
+import {
+ getSourceSpanAtClipTime,
+ splitRetimeAtClipTime,
+} from "@/lib/retime";
+import type { RetimeConfig } from "@/lib/timeline";
+
+describe("retime split", () => {
+ test("measures source span at a clip time", () => {
+ const retime: RetimeConfig = { rate: 2 };
+ expect(getSourceSpanAtClipTime({ clipTime: 5, retime })).toBe(10);
+ });
+
+ test("returns zero for non-positive clip time", () => {
+ expect(getSourceSpanAtClipTime({ clipTime: 0 })).toBe(0);
+ expect(getSourceSpanAtClipTime({ clipTime: -1 })).toBe(0);
+ });
+
+ test("passes the same retime to both halves when splitting", () => {
+ const retime: RetimeConfig = { rate: 1.5 };
+ const result = splitRetimeAtClipTime({ retime, splitClipTime: 3 });
+ expect(result.left).toBe(retime);
+ expect(result.right).toBe(retime);
+ });
+
+ test("returns undefined on both sides when no retime", () => {
+ const result = splitRetimeAtClipTime({ splitClipTime: 3 });
+ expect(result.left).toBeUndefined();
+ expect(result.right).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/lib/retime/audio-stretch.ts b/apps/web/src/lib/retime/audio-stretch.ts
new file mode 100644
index 00000000..51ef38d0
--- /dev/null
+++ b/apps/web/src/lib/retime/audio-stretch.ts
@@ -0,0 +1,182 @@
+import { PitchShifter } from "soundtouchjs";
+import { clampRetimeRate, shouldMaintainPitch } from "@/constants/retime-constants";
+import type { RetimeConfig } from "@/lib/timeline";
+import { getSourceTimeAtClipTime } from "./resolve";
+
+const RATE_EPSILON = 1e-6;
+
+function sampleLinear({
+ channelData,
+ position,
+}: {
+ channelData: Float32Array;
+ position: number;
+}): number {
+ if (position <= 0) {
+ return channelData[0] ?? 0;
+ }
+ const lower = Math.floor(position);
+ const upper = Math.min(channelData.length - 1, lower + 1);
+ if (lower >= channelData.length) {
+ return 0;
+ }
+ const fraction = position - lower;
+ return channelData[lower] * (1 - fraction) + channelData[upper] * fraction;
+}
+
+function buildResampledBuffer({
+ audioContext,
+ sourceBuffer,
+ trimStart,
+ clipDuration,
+ targetSampleRate,
+ retime,
+}: {
+ audioContext: BaseAudioContext;
+ sourceBuffer: AudioBuffer;
+ trimStart: number;
+ clipDuration: number;
+ targetSampleRate: number;
+ retime?: RetimeConfig;
+}): AudioBuffer {
+ const outputLength = Math.max(1, Math.ceil(clipDuration * targetSampleRate));
+ const numChannels = Math.max(1, Math.min(2, sourceBuffer.numberOfChannels));
+ const outputBuffer = audioContext.createBuffer(
+ numChannels,
+ outputLength,
+ targetSampleRate,
+ );
+
+ for (let channel = 0; channel < numChannels; channel++) {
+ const sourceData = sourceBuffer.getChannelData(
+ Math.min(channel, sourceBuffer.numberOfChannels - 1),
+ );
+ const outputData = outputBuffer.getChannelData(channel);
+
+ for (let i = 0; i < outputLength; i++) {
+ const clipTime = i / targetSampleRate;
+ const sourceTime =
+ trimStart + getSourceTimeAtClipTime({ clipTime, retime });
+ outputData[i] = sampleLinear({
+ channelData: sourceData,
+ position: sourceTime * sourceBuffer.sampleRate,
+ });
+ }
+ }
+
+ return outputBuffer;
+}
+
+async function buildPitchPreservedBuffer({
+ sourceBuffer,
+ trimStart,
+ clipDuration,
+ rate,
+ targetSampleRate,
+}: {
+ sourceBuffer: AudioBuffer;
+ trimStart: number;
+ clipDuration: number;
+ rate: number;
+ targetSampleRate: number;
+}): Promise<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
new file mode 100644
index 00000000..4915c203
--- /dev/null
+++ b/apps/web/src/lib/retime/index.ts
@@ -0,0 +1,4 @@
+export * from "./audio-stretch";
+export * from "./presets";
+export * from "./resolve";
+export * from "./split";
diff --git a/apps/web/src/lib/retime/presets.ts b/apps/web/src/lib/retime/presets.ts
new file mode 100644
index 00000000..382320b0
--- /dev/null
+++ b/apps/web/src/lib/retime/presets.ts
@@ -0,0 +1,12 @@
+import type { RetimeConfig } from "@/lib/timeline";
+import { clampRetimeRate } from "@/constants/retime-constants";
+
+export function buildConstantRetime({
+ rate,
+ maintainPitch = false,
+}: {
+ rate: number;
+ maintainPitch?: boolean;
+}): RetimeConfig {
+ return { rate: clampRetimeRate({ rate }), maintainPitch };
+}
diff --git a/apps/web/src/lib/retime/resolve.ts b/apps/web/src/lib/retime/resolve.ts
new file mode 100644
index 00000000..cfcfb6a2
--- /dev/null
+++ b/apps/web/src/lib/retime/resolve.ts
@@ -0,0 +1,48 @@
+import type { RetimeConfig } from "@/lib/timeline";
+import { clampRetimeRate } from "@/constants/retime-constants";
+
+function getSafeRate({ rate }: { rate: number }): number {
+ return clampRetimeRate({ rate });
+}
+
+export function getSourceTimeAtClipTime({
+ clipTime,
+ retime,
+}: {
+ clipTime: number;
+ retime?: RetimeConfig;
+}): number {
+ return clipTime * getSafeRate({ rate: retime?.rate ?? 1 });
+}
+
+export function getClipTimeAtSourceTime({
+ sourceTime,
+ retime,
+}: {
+ sourceTime: number;
+ retime?: RetimeConfig;
+}): number {
+ return sourceTime / getSafeRate({ rate: retime?.rate ?? 1 });
+}
+
+export function getEffectiveRateAt({
+ retime,
+}: {
+ clipTime?: number;
+ retime?: RetimeConfig;
+}): number {
+ return getSafeRate({ rate: retime?.rate ?? 1 });
+}
+
+export function getTimelineDurationForSourceSpan({
+ sourceSpan,
+ retime,
+}: {
+ sourceSpan: number;
+ retime?: RetimeConfig;
+}): number {
+ if (sourceSpan <= 0) {
+ return 0;
+ }
+ return sourceSpan / getSafeRate({ rate: retime?.rate ?? 1 });
+}
diff --git a/apps/web/src/lib/retime/split.ts b/apps/web/src/lib/retime/split.ts
new file mode 100644
index 00000000..12982292
--- /dev/null
+++ b/apps/web/src/lib/retime/split.ts
@@ -0,0 +1,34 @@
+import type { RetimeConfig } from "@/lib/timeline";
+import { getSourceTimeAtClipTime } from "./resolve";
+
+export function getSourceSpanAtClipTime({
+ clipTime,
+ retime,
+}: {
+ clipTime: number;
+ retime?: RetimeConfig;
+}): number {
+ return Math.max(0, getSourceTimeAtClipTime({ clipTime, retime }));
+}
+
+export function splitRetimeAtClipTime({
+ retime,
+}: {
+ retime?: RetimeConfig;
+ splitClipTime: number;
+}): {
+ left: RetimeConfig | undefined;
+ right: RetimeConfig | undefined;
+} {
+ return { left: retime, right: retime };
+}
+
+export function adjustRetimeForTrimChange({
+ retime,
+}: {
+ retime?: RetimeConfig;
+ clipTrimTime: number;
+ side: "start" | "end";
+}): RetimeConfig | undefined {
+ return retime;
+}
diff --git a/apps/web/src/lib/scenes.ts b/apps/web/src/lib/scenes.ts
index 62195669..198116c0 100644
--- a/apps/web/src/lib/scenes.ts
+++ b/apps/web/src/lib/scenes.ts
@@ -1,4 +1,4 @@
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/lib/timeline";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
diff --git a/apps/web/src/lib/selection/constants.ts b/apps/web/src/lib/selection/constants.ts
new file mode 100644
index 00000000..a1aee842
--- /dev/null
+++ b/apps/web/src/lib/selection/constants.ts
@@ -0,0 +1 @@
+export const SELECTABLE_ITEM_ATTRIBUTE = "data-selectable-item";
diff --git a/apps/web/src/lib/selection/context.tsx b/apps/web/src/lib/selection/context.tsx
new file mode 100644
index 00000000..ced49c88
--- /dev/null
+++ b/apps/web/src/lib/selection/context.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+interface SelectionContextValue {
+ selectedIds: string[];
+ anchorId: string | null;
+ highlightedId: string | null;
+ isBoxSelecting: boolean;
+ isSelected: (id: string) => boolean;
+ clearSelection: () => void;
+ handleItemClick: ({
+ event,
+ id,
+ }: {
+ event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>;
+ id: string;
+ }) => void;
+ handleItemMouseDown: ({
+ event,
+ id,
+ }: {
+ event: React.MouseEvent<HTMLDivElement>;
+ id: string;
+ }) => void;
+ registerItem: (id: string, element: HTMLElement | null) => void;
+}
+
+export const SelectionContext = createContext<SelectionContextValue | null>(null);
+
+export function useSelectionContext() {
+ const context = useContext(SelectionContext);
+
+ if (!context) {
+ throw new Error("useSelectionContext must be used within SelectableSurface");
+ }
+
+ return context;
+}
+
+export function useSelection() {
+ const { selectedIds, anchorId, highlightedId, isSelected, clearSelection } =
+ useSelectionContext();
+
+ return {
+ selectedIds,
+ anchorId,
+ highlightedId,
+ isSelected,
+ clearSelection,
+ };
+}
diff --git a/apps/web/src/lib/selection/hit-testing.ts b/apps/web/src/lib/selection/hit-testing.ts
new file mode 100644
index 00000000..ba086425
--- /dev/null
+++ b/apps/web/src/lib/selection/hit-testing.ts
@@ -0,0 +1,50 @@
+function isIntersecting({
+ selectionRect,
+ itemRect,
+}: {
+ selectionRect: DOMRect;
+ itemRect: DOMRect;
+}) {
+ return !(
+ itemRect.right < selectionRect.left ||
+ itemRect.left > selectionRect.right ||
+ itemRect.bottom < selectionRect.top ||
+ itemRect.top > selectionRect.bottom
+ );
+}
+
+function createSelectionRect({
+ startPos,
+ currentPos,
+}: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+}) {
+ return new DOMRect(
+ Math.min(startPos.x, currentPos.x),
+ Math.min(startPos.y, currentPos.y),
+ Math.abs(currentPos.x - startPos.x),
+ Math.abs(currentPos.y - startPos.y),
+ );
+}
+
+export function resolveElementIntersections({
+ startPos,
+ currentPos,
+ elements,
+}: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ elements: Map<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
new file mode 100644
index 00000000..5a9a5cd0
--- /dev/null
+++ b/apps/web/src/lib/selection/hooks/use-box-select.ts
@@ -0,0 +1,170 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type {
+ BoxSelectionSnapshot,
+ ResolveIntersections,
+} from "@/lib/selection/types";
+
+interface SelectionBoxState<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
new file mode 100644
index 00000000..7ed63815
--- /dev/null
+++ b/apps/web/src/lib/selection/hooks/use-selection-scope.ts
@@ -0,0 +1,28 @@
+import { useEffect, useRef } from "react";
+import { useSelectionContext } from "@/lib/selection/context";
+import { activateScope, type ScopeEntry } from "@/lib/selection/scope";
+
+export function useSelectionScope() {
+ const { selectedIds, clearSelection } = useSelectionContext();
+ const hasSelectionRef = useRef(selectedIds.length > 0);
+ const clearSelectionRef = useRef(clearSelection);
+ const hasSelection = selectedIds.length > 0;
+
+ hasSelectionRef.current = hasSelection;
+ clearSelectionRef.current = clearSelection;
+
+ const entryRef = useRef<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
new file mode 100644
index 00000000..b8699ecf
--- /dev/null
+++ b/apps/web/src/lib/selection/index.ts
@@ -0,0 +1,4 @@
+export { SelectableItem } from "@/lib/selection/selectable-item";
+export { SelectableSurface } from "@/lib/selection/selectable-surface";
+export { useSelection } from "@/lib/selection/context";
+export { useSelectionScope } from "@/lib/selection/hooks/use-selection-scope";
diff --git a/apps/web/src/lib/selection/scope.ts b/apps/web/src/lib/selection/scope.ts
new file mode 100644
index 00000000..45bf6b56
--- /dev/null
+++ b/apps/web/src/lib/selection/scope.ts
@@ -0,0 +1,29 @@
+export type ScopeEntry = {
+ hasSelection: () => boolean;
+ clear: () => void;
+};
+
+let activeScope: ScopeEntry | null = null;
+
+export function activateScope({ entry }: { entry: ScopeEntry }): () => void {
+ if (activeScope && activeScope !== entry) {
+ activeScope.clear();
+ }
+
+ activeScope = entry;
+
+ return () => {
+ if (activeScope === entry) {
+ activeScope = null;
+ }
+ };
+}
+
+export function clearActiveScope(): boolean {
+ if (!activeScope?.hasSelection()) {
+ return false;
+ }
+
+ activeScope.clear();
+ return true;
+}
diff --git a/apps/web/src/lib/selection/selectable-item.tsx b/apps/web/src/lib/selection/selectable-item.tsx
new file mode 100644
index 00000000..1d7e74a8
--- /dev/null
+++ b/apps/web/src/lib/selection/selectable-item.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import { forwardRef, useCallback } from "react";
+import { useSelectionContext } from "@/lib/selection/context";
+import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/constants";
+import type { SelectableItemProps } from "@/lib/selection/types";
+import { cn } from "@/utils/ui";
+
+function setForwardedRef<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
new file mode 100644
index 00000000..605b3d18
--- /dev/null
+++ b/apps/web/src/lib/selection/selectable-surface.tsx
@@ -0,0 +1,288 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { SelectionBox } from "@/lib/selection/selection-box";
+import { SelectionContext } from "@/lib/selection/context";
+import { SELECTABLE_ITEM_ATTRIBUTE } from "@/lib/selection/constants";
+import { resolveElementIntersections } from "@/lib/selection/hit-testing";
+import {
+ applyBoxSelection,
+ clearSelection,
+ isSelected as getIsSelected,
+ pruneSelection,
+ replaceSelection,
+ selectRange,
+ toggleSelection,
+} from "@/lib/selection/state";
+import type {
+ SelectableSurfaceProps,
+ SelectionState,
+} from "@/lib/selection/types";
+import { useBoxSelect } from "@/lib/selection/hooks/use-box-select";
+import { cn } from "@/utils/ui";
+
+export function SelectableSurface({
+ orderedIds,
+ children,
+ className,
+ ariaLabel = "Selectable items",
+ revealId = null,
+ onRevealComplete,
+ onSelectionChange,
+}: SelectableSurfaceProps) {
+ const [selectionState, setSelectionState] = useState<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/components/editor/selection-box.tsx b/apps/web/src/lib/selection/selection-box.tsx
index 0b757e36..0b757e36 100644
--- a/apps/web/src/components/editor/selection-box.tsx
+++ b/apps/web/src/lib/selection/selection-box.tsx
diff --git a/apps/web/src/lib/selection/state.ts b/apps/web/src/lib/selection/state.ts
new file mode 100644
index 00000000..b4c454fb
--- /dev/null
+++ b/apps/web/src/lib/selection/state.ts
@@ -0,0 +1,165 @@
+import type {
+ BoxSelectionChange,
+ SelectionState,
+} from "@/lib/selection/types";
+
+function dedupeIds({ ids }: { ids: string[] }) {
+ return [...new Set(ids)];
+}
+
+function getRangeIds({
+ orderedIds,
+ anchorId,
+ targetId,
+}: {
+ orderedIds: string[];
+ anchorId: string;
+ targetId: string;
+}) {
+ const anchorIndex = orderedIds.indexOf(anchorId);
+ const targetIndex = orderedIds.indexOf(targetId);
+
+ if (anchorIndex === -1 || targetIndex === -1) {
+ return [targetId];
+ }
+
+ const rangeStart = Math.min(anchorIndex, targetIndex);
+ const rangeEnd = Math.max(anchorIndex, targetIndex);
+ return orderedIds.slice(rangeStart, rangeEnd + 1);
+}
+
+export function replaceSelection({
+ ids,
+ anchorId,
+}: {
+ ids: string[];
+ anchorId?: string | null;
+}): SelectionState {
+ const selectedIds = dedupeIds({ ids });
+ return {
+ selectedIds,
+ anchorId: anchorId ?? selectedIds[selectedIds.length - 1] ?? null,
+ };
+}
+
+export function clearSelection(): SelectionState {
+ return {
+ selectedIds: [],
+ anchorId: null,
+ };
+}
+
+export function pruneSelection({
+ state,
+ orderedIds,
+}: {
+ state: SelectionState;
+ orderedIds: string[];
+}): SelectionState {
+ const validIds = new Set(orderedIds);
+ const selectedIds = state.selectedIds.filter((id) => validIds.has(id));
+ const anchorId =
+ state.anchorId && validIds.has(state.anchorId)
+ ? state.anchorId
+ : selectedIds[selectedIds.length - 1] ?? null;
+ const isUnchanged =
+ selectedIds.length === state.selectedIds.length &&
+ anchorId === state.anchorId;
+
+ if (isUnchanged) {
+ return state;
+ }
+
+ return {
+ selectedIds,
+ anchorId,
+ };
+}
+
+export function isSelected({
+ state,
+ id,
+}: {
+ state: SelectionState;
+ id: string;
+}) {
+ return state.selectedIds.includes(id);
+}
+
+export function toggleSelection({
+ state,
+ id,
+}: {
+ state: SelectionState;
+ id: string;
+}): SelectionState {
+ if (isSelected({ state, id })) {
+ const selectedIds = state.selectedIds.filter((selectedId) => selectedId !== id);
+ const anchorId =
+ state.anchorId === id
+ ? selectedIds[selectedIds.length - 1] ?? null
+ : state.anchorId;
+ return replaceSelection({
+ ids: selectedIds,
+ anchorId,
+ });
+ }
+
+ return replaceSelection({
+ ids: [...state.selectedIds, id],
+ anchorId: id,
+ });
+}
+
+export function selectRange({
+ state,
+ orderedIds,
+ targetId,
+ isAdditive,
+}: {
+ state: SelectionState;
+ orderedIds: string[];
+ targetId: string;
+ isAdditive: boolean;
+}): SelectionState {
+ const anchorId =
+ state.anchorId ??
+ state.selectedIds[state.selectedIds.length - 1] ??
+ targetId;
+ const rangeIds = getRangeIds({
+ orderedIds,
+ anchorId,
+ targetId,
+ });
+ const selectedIds = isAdditive
+ ? dedupeIds({
+ ids: [...state.selectedIds, ...rangeIds],
+ })
+ : rangeIds;
+
+ return replaceSelection({
+ ids: selectedIds,
+ anchorId,
+ });
+}
+
+export function applyBoxSelection({
+ intersectedIds,
+ initialSelectedIds,
+ initialAnchorId,
+ isAdditive,
+}: BoxSelectionChange): SelectionState {
+ const selectedIds = isAdditive
+ ? dedupeIds({
+ ids: [...initialSelectedIds, ...intersectedIds],
+ })
+ : intersectedIds;
+ const anchorId = isAdditive
+ ? initialAnchorId ?? intersectedIds[intersectedIds.length - 1] ?? null
+ : intersectedIds[intersectedIds.length - 1] ?? null;
+
+ return replaceSelection({
+ ids: selectedIds,
+ anchorId,
+ });
+}
diff --git a/apps/web/src/lib/selection/types.ts b/apps/web/src/lib/selection/types.ts
new file mode 100644
index 00000000..94fd7abe
--- /dev/null
+++ b/apps/web/src/lib/selection/types.ts
@@ -0,0 +1,39 @@
+export interface SelectionState {
+ selectedIds: string[];
+ anchorId: string | null;
+}
+
+export interface BoxSelectionSnapshot<TId = string> {
+ initialSelectedIds: TId[];
+ initialAnchorId: TId | null;
+}
+
+export interface BoxSelectionChange<TId = string>
+ extends BoxSelectionSnapshot<TId> {
+ intersectedIds: TId[];
+ isAdditive: boolean;
+}
+
+export type ResolveIntersections<TId = string> = ({
+ startPos,
+ currentPos,
+}: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+}) => TId[];
+
+export interface SelectableSurfaceProps {
+ orderedIds: string[];
+ children: React.ReactNode;
+ className?: string;
+ ariaLabel?: string;
+ revealId?: string | null;
+ onRevealComplete?: () => void;
+ onSelectionChange?: (state: SelectionState) => void;
+}
+
+export interface SelectableItemProps
+ extends React.HTMLAttributes<HTMLDivElement> {
+ id: string;
+ children: React.ReactNode;
+}
diff --git a/apps/web/src/lib/shaders/jfa-init.frag.glsl b/apps/web/src/lib/shaders/jfa-init.frag.glsl
new file mode 100644
index 00000000..d199d234
--- /dev/null
+++ b/apps/web/src/lib/shaders/jfa-init.frag.glsl
@@ -0,0 +1,25 @@
+precision mediump float;
+
+uniform sampler2D u_texture;
+uniform vec2 u_resolution;
+uniform float u_invert;
+
+varying vec2 v_texCoord;
+
+void main() {
+ float mask = texture2D(u_texture, v_texCoord).r;
+ bool isSeed = u_invert > 0.5 ? mask < 0.5 : mask > 0.5;
+
+ if (isSeed) {
+ vec2 pixelCoord = floor(v_texCoord * u_resolution);
+ float x = pixelCoord.x;
+ float y = pixelCoord.y;
+ float xHi = floor(x / 256.0);
+ float xLo = x - xHi * 256.0;
+ float yHi = floor(y / 256.0);
+ float yLo = y - yHi * 256.0;
+ gl_FragColor = vec4(xHi / 255.0, xLo / 255.0, yHi / 255.0, yLo / 255.0);
+ } else {
+ gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
+ }
+}
diff --git a/apps/web/src/lib/shaders/jfa-step.frag.glsl b/apps/web/src/lib/shaders/jfa-step.frag.glsl
new file mode 100644
index 00000000..5a95f497
--- /dev/null
+++ b/apps/web/src/lib/shaders/jfa-step.frag.glsl
@@ -0,0 +1,60 @@
+precision mediump float;
+
+uniform sampler2D u_texture;
+uniform vec2 u_resolution;
+uniform float u_step_size;
+
+varying vec2 v_texCoord;
+
+vec2 decodeSeed(vec4 encoded) {
+ float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5);
+ float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5);
+ return vec2(x, y);
+}
+
+vec4 encodeSeed(vec2 seed) {
+ float xHi = floor(seed.x / 256.0);
+ float xLo = seed.x - xHi * 256.0;
+ float yHi = floor(seed.y / 256.0);
+ float yLo = seed.y - yHi * 256.0;
+ return vec4(xHi / 255.0, xLo / 255.0, yHi / 255.0, yLo / 255.0);
+}
+
+bool isNoSeed(vec4 encoded) {
+ return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99;
+}
+
+void main() {
+ vec2 pixelCoord = floor(v_texCoord * u_resolution);
+ vec2 texelSize = 1.0 / u_resolution;
+
+ float bestDist = 1e10;
+ vec2 bestSeed = vec2(65535.0);
+
+ for (int dy = -1; dy <= 1; dy++) {
+ for (int dx = -1; dx <= 1; dx++) {
+ vec2 offset = vec2(float(dx), float(dy)) * u_step_size;
+ vec2 sampleUV = v_texCoord + offset * texelSize;
+
+ if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0)
+ continue;
+
+ vec4 encoded = texture2D(u_texture, sampleUV);
+ if (isNoSeed(encoded))
+ continue;
+
+ vec2 seed = decodeSeed(encoded);
+ float dist = distance(pixelCoord, seed);
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestSeed = seed;
+ }
+ }
+ }
+
+ if (bestDist < 1e9) {
+ gl_FragColor = encodeSeed(bestSeed);
+ } else {
+ gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
+ }
+}
diff --git a/apps/web/src/types/sounds.ts b/apps/web/src/lib/sounds/types.ts
index eecf7701..eecf7701 100644
--- a/apps/web/src/types/sounds.ts
+++ b/apps/web/src/lib/sounds/types.ts
diff --git a/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts
index 573dc251..3236e213 100644
--- a/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts
+++ b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts
@@ -3,13 +3,13 @@ import { buildStickerId, parseStickerId } from "../sticker-id";
describe("sticker-id strict mode", () => {
test("parses provider-prefixed IDs", () => {
- expect(parseStickerId({ stickerId: "icons:mdi:home" })).toEqual({
- providerId: "icons",
- providerValue: "mdi:home",
+ expect(parseStickerId({ stickerId: "flags:US" })).toEqual({
+ providerId: "flags",
+ providerValue: "US",
});
- expect(parseStickerId({ stickerId: "emoji:noto:grinning-face" })).toEqual({
- providerId: "emoji",
- providerValue: "noto:grinning-face",
+ expect(parseStickerId({ stickerId: "shapes:circle" })).toEqual({
+ providerId: "shapes",
+ providerValue: "circle",
});
});
@@ -19,7 +19,7 @@ describe("sticker-id strict mode", () => {
test("throws for malformed IDs", () => {
expect(() => parseStickerId({ stickerId: "" })).toThrow();
- expect(() => parseStickerId({ stickerId: "icons:" })).toThrow();
+ expect(() => parseStickerId({ stickerId: "flags:" })).toThrow();
expect(() => parseStickerId({ stickerId: ":mdi:home" })).toThrow();
});
diff --git a/apps/web/src/lib/stickers/index.ts b/apps/web/src/lib/stickers/index.ts
index 1f924c6d..47be18fe 100644
--- a/apps/web/src/lib/stickers/index.ts
+++ b/apps/web/src/lib/stickers/index.ts
@@ -1,10 +1,20 @@
-import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
-import type { StickerCategory } from "@/types/stickers";
-import { getAllProviders, getProvider } from "./registry";
+import {
+ STICKER_CATEGORIES,
+ STICKER_INTRINSIC_SIZE_FALLBACK,
+} from "@/constants/sticker-constants";
+import type { StickerCategory } from "@/lib/stickers/types";
+import { stickersRegistry } from "./registry";
import { resolveStickerId } from "./resolver";
import { registerDefaultStickerProviders } from "./providers";
-import type { StickerProvider, StickerSearchResult } from "./types";
+import { parseStickerId } from "./sticker-id";
+import type {
+ StickerBrowseResult,
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "./types";
+const DEFAULT_BROWSE_LIMIT = 12;
const DEFAULT_SEARCH_LIMIT = 100;
function mergeSearchResults({
@@ -46,7 +56,46 @@ function getProviderByCategory({
}
try {
- return getProvider({ providerId: category });
+ return stickersRegistry.get(category);
+ } catch {
+ return null;
+ }
+}
+
+function getEmptyBrowseResult(): StickerBrowseResult {
+ return {
+ sections: [],
+ };
+}
+
+function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
+ const stickerIdParts = stickerId.split(":");
+ if (stickerIdParts.length <= 1) {
+ return stickerId;
+ }
+ return (
+ stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
+ stickerId
+ );
+}
+
+function toRecentStickerItem({
+ stickerId,
+}: {
+ stickerId: string;
+}): StickerItem | null {
+ try {
+ const { providerId } = parseStickerId({ stickerId });
+ return {
+ id: stickerId,
+ provider: providerId,
+ name: getStickerNameFromId({ stickerId }),
+ previewUrl: resolveStickerId({
+ stickerId,
+ options: { width: 64, height: 64 },
+ }),
+ metadata: {},
+ };
} catch {
return null;
}
@@ -79,7 +128,7 @@ export async function searchStickers({
});
}
- const providers = getAllProviders();
+ const providers = stickersRegistry.getAll();
if (providers.length === 0) {
return {
items: [],
@@ -110,64 +159,157 @@ export async function searchStickers({
});
}
-export async function browseStickers({
- category,
- page = 1,
+export async function searchAll({
+ query,
limit = DEFAULT_SEARCH_LIMIT,
}: {
- category: StickerCategory;
- page?: number;
+ query: string;
limit?: number;
-}): Promise<StickerSearchResult> {
+}): Promise<StickerBrowseResult> {
registerDefaultStickerProviders({});
- const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
- if (effectiveCategory !== "all") {
- const provider = getProviderByCategory({ category: effectiveCategory });
- if (!provider) {
- return {
- items: [],
- total: 0,
- hasMore: false,
- };
+ const providers = stickersRegistry.getAll();
+ if (providers.length === 0) {
+ return { sections: [] };
+ }
+
+ const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
+ const settledResults = await Promise.allSettled(
+ providers.map(async (provider) => {
+ const result = await provider.search({
+ query,
+ options: { limit: perProviderLimit },
+ });
+ return { provider, result };
+ }),
+ );
+
+ const sections: StickerBrowseResult["sections"] = [];
+ for (const settled of settledResults) {
+ if (settled.status !== "fulfilled") {
+ continue;
}
- return provider.browse({
- options: { page, limit },
+ const { provider, result } = settled.value;
+ if (result.items.length === 0) {
+ continue;
+ }
+ const category = provider.id as StickerCategory;
+ sections.push({
+ id: category,
+ title: STICKER_CATEGORIES[category] ?? provider.id,
+ items: result.items,
+ hasMore: result.hasMore,
+ layout: "grid",
});
}
- const providers = getAllProviders();
- if (providers.length === 0) {
- return {
- items: [],
- total: 0,
- hasMore: false,
- };
+ return { sections };
+}
+
+export async function browseCategory({
+ category,
+}: {
+ category: StickerCategory;
+}): Promise<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 perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
const settledResults = await Promise.allSettled(
- providers.map((provider) =>
- provider.browse({
- options: { page, limit: perProviderLimit },
- }),
- ),
+ stickersRegistry.getAll().map(async (provider) => {
+ const browseResult = await provider.browse({
+ options: { limit },
+ });
+ const firstSection = browseResult.sections[0];
+
+ if (!firstSection || firstSection.items.length === 0) {
+ return null;
+ }
+
+ const category = provider.id as StickerCategory;
+ return {
+ ...firstSection,
+ id: category,
+ title: STICKER_CATEGORIES[category] ?? firstSection.title,
+ layout: "row" as const,
+ action: {
+ type: "see-all" as const,
+ category,
+ sectionId: firstSection.id,
+ },
+ };
+ }),
);
- const fulfilledResults = settledResults
- .filter(
- (result): result is PromiseFulfilledResult<StickerSearchResult> =>
- result.status === "fulfilled",
- )
- .map((result) => result.value);
+ for (const result of settledResults) {
+ if (result.status === "fulfilled" && result.value) {
+ sections.push(result.value);
+ }
+ }
- return mergeSearchResults({
- results: fulfilledResults,
+ return { sections };
+}
+
+export async function resolveStickerIntrinsicSize({
+ stickerId,
+}: {
+ stickerId: string;
+}): Promise<{ width: number; height: number }> {
+ const url = resolveStickerId({ stickerId });
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () =>
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
+ img.onerror = () =>
+ resolve({
+ width: STICKER_INTRINSIC_SIZE_FALLBACK,
+ height: STICKER_INTRINSIC_SIZE_FALLBACK,
+ });
+ img.src = url;
});
}
export { resolveStickerId };
+export { resolveQueryToRegions, getRegionLabel } from "./providers/flags";
export type {
+ StickerBrowseResult,
+ StickerBrowseSection,
+ StickerCategory,
StickerItem,
StickerProvider,
StickerResolveOptions,
diff --git a/apps/web/src/lib/stickers/providers/countries-data.ts b/apps/web/src/lib/stickers/providers/countries-data.ts
new file mode 100644
index 00000000..1bcc1f50
--- /dev/null
+++ b/apps/web/src/lib/stickers/providers/countries-data.ts
@@ -0,0 +1,1819 @@
+export const REGIONS = [
+ { id: "Western Europe", aliases: ["west europe"] },
+ { id: "Eastern Europe", aliases: ["east europe"] },
+ { id: "Northern Europe", aliases: ["north europe"] },
+ { id: "Southern Europe", aliases: ["south europe"] },
+ { id: "South Asia", aliases: ["southern asia"] },
+ { id: "Southeast Asia", aliases: ["south east asia"] },
+ { id: "East Asia", aliases: ["eastern asia", "far east"] },
+ { id: "Central Asia", aliases: [] },
+ { id: "Middle East", aliases: ["west asia"] },
+ { id: "North Africa", aliases: ["northern africa"] },
+ { id: "Sub-Saharan Africa", aliases: ["subsaharan africa"] },
+ { id: "North America", aliases: ["northern america"] },
+ { id: "South America", aliases: ["southern america", "latin america"] },
+ { id: "Central America", aliases: ["central am"] },
+ { id: "Caribbean", aliases: ["caribbean islands"] },
+ { id: "Oceania", aliases: ["pacific", "pacific islands"] },
+ { id: "Antarctica", aliases: [] },
+ { id: "Atlantic Ocean", aliases: ["atlantic"] },
+ { id: "North Atlantic", aliases: [] },
+] as const;
+
+export type RegionId = (typeof REGIONS)[number]["id"];
+
+export const REGION_GROUPS: Partial<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/emoji.ts b/apps/web/src/lib/stickers/providers/emoji.ts
deleted file mode 100644
index cc198236..00000000
--- a/apps/web/src/lib/stickers/providers/emoji.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import {
- POPULAR_COLLECTIONS,
- getIconSvgUrl,
- searchIcons,
-} from "@/lib/iconify-api";
-import { buildStickerId, parseStickerId } from "../sticker-id";
-import type {
- StickerItem,
- StickerProvider,
- StickerSearchResult,
-} from "../types";
-
-const EMOJI_PROVIDER_ID = "emoji";
-const DEFAULT_SEARCH_LIMIT = 100;
-
-const EMOJI_PREFIXES = POPULAR_COLLECTIONS.emoji.map(
- (collection) => collection.prefix,
-);
-
-const DEFAULT_EMOJI_BROWSE = [
- "noto:grinning-face",
- "noto:smiling-face-with-heart-eyes",
- "noto:fire",
- "noto:rocket",
- "noto:party-popper",
- "noto:clapping-hands",
- "noto:sparkles",
- "noto:red-heart",
- "noto:thumbs-up",
- "noto:eyes",
- "noto:thinking-face",
- "noto:hundred-points",
-];
-
-function getDisplayNameFromIconName({
- iconName,
-}: {
- iconName: string;
-}): string {
- const parts = iconName.split(":");
- const rawName = parts[parts.length - 1] ?? iconName;
- return rawName.replaceAll("-", " ").replaceAll("_", " ");
-}
-
-function toStickerItem({ iconName }: { iconName: string }): StickerItem {
- return {
- id: buildStickerId({
- providerId: EMOJI_PROVIDER_ID,
- providerValue: iconName,
- }),
- provider: EMOJI_PROVIDER_ID,
- name: getDisplayNameFromIconName({ iconName }),
- previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }),
- metadata: { iconName },
- };
-}
-
-function computeHasMore({
- total,
- limit,
- start = 0,
-}: {
- total: number;
- limit: number;
- start?: number;
-}): boolean {
- return start + limit < total;
-}
-
-export const emojiProvider: StickerProvider = {
- id: EMOJI_PROVIDER_ID,
- async search({
- query,
- options,
- }: {
- query: string;
- options?: { limit?: number };
- }): Promise<StickerSearchResult> {
- const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT;
- const searchResult = await searchIcons(query, limit, EMOJI_PREFIXES);
- return {
- items: searchResult.icons.map((iconName) => toStickerItem({ iconName })),
- total: searchResult.total,
- hasMore: computeHasMore({
- total: searchResult.total,
- limit: searchResult.limit,
- start: searchResult.start,
- }),
- };
- },
- async browse({
- options,
- }: {
- options?: { page?: number; limit?: number };
- }): Promise<StickerSearchResult> {
- const limit = options?.limit ?? DEFAULT_EMOJI_BROWSE.length;
- const items = DEFAULT_EMOJI_BROWSE.slice(0, limit).map((iconName) =>
- toStickerItem({ iconName }),
- );
- return {
- items,
- total: items.length,
- hasMore: false,
- };
- },
- resolveUrl({
- stickerId,
- options,
- }: {
- stickerId: string;
- options?: { width?: number; height?: number };
- }): string {
- const { providerValue } = parseStickerId({ stickerId });
- return getIconSvgUrl(providerValue, {
- width: options?.width,
- height: options?.height,
- });
- },
-};
diff --git a/apps/web/src/lib/stickers/providers/flags.ts b/apps/web/src/lib/stickers/providers/flags.ts
index 91cc5489..0cca292f 100644
--- a/apps/web/src/lib/stickers/providers/flags.ts
+++ b/apps/web/src/lib/stickers/providers/flags.ts
@@ -1,23 +1,17 @@
import { buildStickerId, parseStickerId } from "../sticker-id";
import type {
+ StickerBrowseResult,
StickerItem,
StickerProvider,
StickerSearchResult,
} from "../types";
+import { REGIONS, REGION_GROUPS } from "./countries-data";
+import type { CountryRecord, RegionId } from "./countries-data";
const FLAGS_PROVIDER_ID = "flags";
-const FLAGS_DATASET_URL = "/countries.json";
const DEFAULT_SEARCH_LIMIT = 100;
const DEFAULT_FLAGS_BASE_URL = "/flags";
-type CountryRecord = {
- name: string;
- code: string;
- languages?: string[];
- flag_colors?: string[];
- region?: string;
-};
-
let countriesPromise: Promise<CountryRecord[]> | null = null;
function getFlagsBaseUrl(): string {
@@ -25,7 +19,7 @@ function getFlagsBaseUrl(): string {
}
function buildFlagUrl({ code }: { code: string }): string {
- const normalizedCode = code.toUpperCase();
+ const normalizedCode = code.toLowerCase();
return `${getFlagsBaseUrl()}/${encodeURIComponent(normalizedCode)}.svg`;
}
@@ -34,13 +28,8 @@ async function loadCountries(): Promise<CountryRecord[]> {
return countriesPromise;
}
- countriesPromise = fetch(FLAGS_DATASET_URL)
- .then(async (response) => {
- if (!response.ok) {
- throw new Error(`Failed to load countries: ${response.status}`);
- }
- return (await response.json()) as CountryRecord[];
- })
+ countriesPromise = import("./countries-data")
+ .then((m) => m.COUNTRIES)
.catch((error) => {
console.error("Failed to load countries dataset:", error);
return [];
@@ -72,6 +61,44 @@ function normalizeQuery({ query }: { query: string }): string {
return query.trim().toLowerCase();
}
+function findMatchingRegions({
+ query,
+}: {
+ query: string;
+}): (typeof REGIONS)[number][] {
+ return REGIONS.filter(
+ (r) =>
+ r.id.toLowerCase() === query ||
+ r.aliases.some((alias) => alias === query),
+ );
+}
+
+export function resolveQueryToRegions({
+ query,
+}: {
+ query: string;
+}): Set<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,
@@ -83,15 +110,16 @@ function filterCountriesByQuery({
return countries;
}
+ const regionIds = resolveQueryToRegions({ query });
+
+ if (regionIds) {
+ return countries.filter((country) => country.region && regionIds.has(country.region));
+ }
+
return countries.filter((country) => {
const normalizedName = country.name.toLowerCase();
const normalizedCode = country.code.toLowerCase();
- const normalizedRegion = country.region?.toLowerCase() ?? "";
- return (
- normalizedName.includes(query) ||
- normalizedCode.includes(query) ||
- normalizedRegion.includes(query)
- );
+ return normalizedName.includes(query) || normalizedCode.includes(query);
});
}
@@ -102,13 +130,15 @@ function paginateCountries({
countries: CountryRecord[];
options?: { page?: number; limit?: number };
}): { items: CountryRecord[]; hasMore: boolean; total: number } {
- const page = Math.max(1, options?.page ?? 1);
- const limit = Math.max(1, options?.limit ?? DEFAULT_SEARCH_LIMIT);
+ if (options?.limit === undefined) {
+ return { items: countries, hasMore: false, total: countries.length };
+ }
+ const page = Math.max(1, options.page ?? 1);
+ const limit = Math.max(1, options.limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
- const pagedItems = countries.slice(startIndex, endIndex);
return {
- items: pagedItems,
+ items: countries.slice(startIndex, endIndex),
hasMore: endIndex < countries.length,
total: countries.length,
};
@@ -146,13 +176,18 @@ export const flagsProvider: StickerProvider = {
options,
}: {
options?: { page?: number; limit?: number };
- }): Promise<StickerSearchResult> {
+ }): Promise<StickerBrowseResult> {
const countries = await loadCountries();
const paged = paginateCountries({ countries, options });
return {
- items: paged.items.map((country) => toStickerItem({ country })),
- total: paged.total,
- hasMore: paged.hasMore,
+ sections: [
+ {
+ id: "all",
+ items: paged.items.map((country) => toStickerItem({ country })),
+ hasMore: paged.hasMore,
+ layout: "grid",
+ },
+ ],
};
},
resolveUrl({
diff --git a/apps/web/src/lib/stickers/providers/icons.ts b/apps/web/src/lib/stickers/providers/icons.ts
deleted file mode 100644
index 06b2a4c2..00000000
--- a/apps/web/src/lib/stickers/providers/icons.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import {
- POPULAR_COLLECTIONS,
- getIconSvgUrl,
- searchIcons,
-} from "@/lib/iconify-api";
-import { buildStickerId, parseStickerId } from "../sticker-id";
-import type {
- StickerItem,
- StickerProvider,
- StickerSearchResult,
-} from "../types";
-
-const ICONS_PROVIDER_ID = "icons";
-const DEFAULT_SEARCH_LIMIT = 100;
-
-const ICONS_PREFIXES = Array.from(
- new Set(
- [...POPULAR_COLLECTIONS.general, ...POPULAR_COLLECTIONS.brands].map(
- (collection) => collection.prefix,
- ),
- ),
-);
-
-const DEFAULT_ICONS_BROWSE = [
- "mdi:home",
- "mdi:star",
- "mdi:heart",
- "mdi:check-circle",
- "mdi:account",
- "mdi:camera",
- "mdi:music",
- "mdi:map-marker",
- "mdi:calendar",
- "mdi:lightning-bolt",
- "mdi:cog",
- "mdi:rocket",
-];
-
-function getDisplayNameFromIconName({
- iconName,
-}: {
- iconName: string;
-}): string {
- const [, rawName = iconName] = iconName.split(":");
- return rawName.replaceAll("-", " ").replaceAll("_", " ");
-}
-
-function toStickerItem({ iconName }: { iconName: string }): StickerItem {
- return {
- id: buildStickerId({
- providerId: ICONS_PROVIDER_ID,
- providerValue: iconName,
- }),
- provider: ICONS_PROVIDER_ID,
- name: getDisplayNameFromIconName({ iconName }),
- previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }),
- metadata: { iconName },
- };
-}
-
-function computeHasMore({
- total,
- limit,
- start = 0,
-}: {
- total: number;
- limit: number;
- start?: number;
-}): boolean {
- return start + limit < total;
-}
-
-export const iconsProvider: StickerProvider = {
- id: ICONS_PROVIDER_ID,
- async search({
- query,
- options,
- }: {
- query: string;
- options?: { limit?: number };
- }): Promise<StickerSearchResult> {
- const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT;
- const searchResult = await searchIcons(query, limit, ICONS_PREFIXES);
- return {
- items: searchResult.icons.map((iconName) => toStickerItem({ iconName })),
- total: searchResult.total,
- hasMore: computeHasMore({
- total: searchResult.total,
- limit: searchResult.limit,
- start: searchResult.start,
- }),
- };
- },
- async browse({
- options,
- }: {
- options?: { page?: number; limit?: number };
- }): Promise<StickerSearchResult> {
- const limit = options?.limit ?? DEFAULT_ICONS_BROWSE.length;
- const items = DEFAULT_ICONS_BROWSE.slice(0, limit).map((iconName) =>
- toStickerItem({ iconName }),
- );
- return {
- items,
- total: items.length,
- hasMore: false,
- };
- },
- resolveUrl({
- stickerId,
- options,
- }: {
- stickerId: string;
- options?: { width?: number; height?: number };
- }): string {
- const { providerValue } = parseStickerId({ stickerId });
- return getIconSvgUrl(providerValue, {
- width: options?.width,
- height: options?.height,
- });
- },
-};
diff --git a/apps/web/src/lib/stickers/providers/index.ts b/apps/web/src/lib/stickers/providers/index.ts
index 3c4faee9..b0a298cb 100644
--- a/apps/web/src/lib/stickers/providers/index.ts
+++ b/apps/web/src/lib/stickers/providers/index.ts
@@ -1,13 +1,11 @@
-import { hasProvider, registerProvider } from "../registry";
-import type { StickerProvider } from "@/types/stickers";
-import { emojiProvider } from "./emoji";
+import { stickersRegistry } from "../registry";
+import type { StickerProvider } from "@/lib/stickers/types";
import { flagsProvider } from "./flags";
-import { iconsProvider } from "./icons";
+import { logosProvider } from "./logos";
import { shapesProvider } from "./shapes";
const defaultProviders: StickerProvider[] = [
- iconsProvider,
- emojiProvider,
+ logosProvider,
flagsProvider,
shapesProvider,
];
@@ -18,9 +16,9 @@ export function registerDefaultStickerProviders({
providersToRegister?: StickerProvider[];
} = {}): void {
for (const provider of providersToRegister) {
- if (hasProvider({ providerId: provider.id })) {
+ if (stickersRegistry.has(provider.id)) {
continue;
}
- registerProvider({ provider });
+ stickersRegistry.register(provider.id, provider);
}
}
diff --git a/apps/web/src/lib/stickers/providers/logos.ts b/apps/web/src/lib/stickers/providers/logos.ts
new file mode 100644
index 00000000..5be2e9a2
--- /dev/null
+++ b/apps/web/src/lib/stickers/providers/logos.ts
@@ -0,0 +1,31 @@
+import type {
+ StickerBrowseResult,
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "../types";
+
+const LOGOS_PROVIDER_ID = "logos";
+
+const EMPTY_SEARCH_RESULT: StickerSearchResult = {
+ items: [] as StickerItem[],
+ total: 0,
+ hasMore: false,
+};
+
+const EMPTY_BROWSE_RESULT: StickerBrowseResult = {
+ sections: [],
+};
+
+export const logosProvider: StickerProvider = {
+ id: LOGOS_PROVIDER_ID,
+ async search(): Promise<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
index 81873dcb..a23f14a7 100644
--- a/apps/web/src/lib/stickers/providers/shapes.ts
+++ b/apps/web/src/lib/stickers/providers/shapes.ts
@@ -1,5 +1,8 @@
+import { buildGraphicPreviewUrl, buildDefaultGraphicInstance, graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+import type { ParamValues } from "@/lib/params";
import { buildStickerId, parseStickerId } from "../sticker-id";
import type {
+ StickerBrowseResult,
StickerItem,
StickerProvider,
StickerSearchResult,
@@ -7,60 +10,140 @@ import type {
const SHAPES_PROVIDER_ID = "shapes";
-const SHAPES = [
- { key: "circle", name: "Circle" },
- { key: "square", name: "Square" },
- { key: "triangle", name: "Triangle" },
- { key: "star", name: "Star" },
- { key: "hexagon", name: "Hexagon" },
- { key: "diamond", name: "Diamond" },
-] as const;
+type ShapeGraphicPreset = {
+ shapeKey: string;
+ name: string;
+ definitionId: string;
+ params?: ParamValues;
+};
+
+const LEGACY_SHAPE_PRESETS: Record<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 {
- return `/shapes/${shapeKey}.svg`;
+ const preset = getShapePreset({ shapeKey });
+ if (!preset) {
+ return buildGraphicPreviewUrl({ definitionId: "rectangle" });
+ }
+ return buildGraphicPreviewUrl({
+ definitionId: preset.definitionId,
+ params: getShapeParams({ preset }),
+ });
}
function toStickerItem({
- shape,
+ preset,
}: {
- shape: (typeof SHAPES)[number];
+ preset: ShapeGraphicPreset;
}): StickerItem {
return {
id: buildStickerId({
providerId: SHAPES_PROVIDER_ID,
- providerValue: shape.key,
+ providerValue: preset.shapeKey,
}),
provider: SHAPES_PROVIDER_ID,
- name: shape.name,
- previewUrl: buildShapeUrl({ shapeKey: shape.key }),
- metadata: { shape: shape.key },
+ name: preset.name,
+ previewUrl: buildShapeUrl({ shapeKey: preset.shapeKey }),
+ metadata: {
+ definitionId: preset.definitionId,
+ params: preset.params ?? {},
+ },
};
}
-function filterShapesByQuery({
- query,
-}: {
- query: string;
-}): Array<(typeof SHAPES)[number]> {
+function filterShapesByQuery({ query }: { query: string }): ShapeGraphicPreset[] {
const normalizedQuery = query.trim().toLowerCase();
+ const presets = getShapePresets();
if (!normalizedQuery) {
- return [...SHAPES];
+ return presets;
}
- return SHAPES.filter((shape) =>
- shape.name.toLowerCase().includes(normalizedQuery),
- );
+ return presets.filter((preset) => {
+ const definition = graphicsRegistry.get(preset.definitionId);
+ return (
+ preset.name.toLowerCase().includes(normalizedQuery) ||
+ definition.keywords.some((keyword) =>
+ keyword.toLowerCase().includes(normalizedQuery),
+ )
+ );
+ });
}
function paginateShapes({
shapes,
options,
}: {
- shapes: Array<(typeof SHAPES)[number]>;
+ shapes: ShapeGraphicPreset[];
options?: { page?: number; limit?: number };
-}): { items: Array<(typeof SHAPES)[number]>; hasMore: boolean; total: number } {
+}): { items: ShapeGraphicPreset[]; hasMore: boolean; total: number } {
const page = Math.max(1, options?.page ?? 1);
- const limit = Math.max(1, options?.limit ?? SHAPES.length);
+ const limit = Math.max(1, options?.limit ?? getShapePresets().length);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const pagedItems = shapes.slice(startIndex, endIndex);
@@ -83,10 +166,10 @@ export const shapesProvider: StickerProvider = {
const filteredShapes = filterShapesByQuery({ query });
const paged = paginateShapes({
shapes: filteredShapes,
- options: { page: 1, limit: options?.limit ?? SHAPES.length },
+ options: { page: 1, limit: options?.limit ?? getShapePresets().length },
});
return {
- items: paged.items.map((shape) => toStickerItem({ shape })),
+ items: paged.items.map((preset) => toStickerItem({ preset })),
total: paged.total,
hasMore: paged.hasMore,
};
@@ -95,15 +178,20 @@ export const shapesProvider: StickerProvider = {
options,
}: {
options?: { page?: number; limit?: number };
- }): Promise<StickerSearchResult> {
+ }): Promise<StickerBrowseResult> {
const paged = paginateShapes({
- shapes: [...SHAPES],
+ shapes: getShapePresets(),
options,
});
return {
- items: paged.items.map((shape) => toStickerItem({ shape })),
- total: paged.total,
- hasMore: paged.hasMore,
+ sections: [
+ {
+ id: "all",
+ items: paged.items.map((preset) => toStickerItem({ preset })),
+ hasMore: paged.hasMore,
+ layout: "grid",
+ },
+ ],
};
},
resolveUrl({
@@ -112,7 +200,7 @@ export const shapesProvider: StickerProvider = {
stickerId: string;
options?: { width?: number; height?: number };
}): string {
- const { providerValue } = parseStickerId({ stickerId });
- return buildShapeUrl({ shapeKey: providerValue });
+ const preset = parseShapeStickerId({ stickerId });
+ return buildShapeUrl({ shapeKey: preset?.shapeKey ?? "rectangle" });
},
};
diff --git a/apps/web/src/lib/stickers/registry.ts b/apps/web/src/lib/stickers/registry.ts
index 0fbad594..7148c09d 100644
--- a/apps/web/src/lib/stickers/registry.ts
+++ b/apps/web/src/lib/stickers/registry.ts
@@ -1,31 +1,10 @@
-import type { StickerProvider } from "@/types/stickers";
+import type { StickerProvider } from "@/lib/stickers/types";
+import { DefinitionRegistry } from "@/lib/registry";
-const providers = new Map<string, StickerProvider>();
-
-export function registerProvider({
- provider,
-}: {
- provider: StickerProvider;
-}): void {
- providers.set(provider.id, provider);
-}
-
-export function hasProvider({ providerId }: { providerId: string }): boolean {
- return providers.has(providerId);
-}
-
-export function getProvider({
- providerId,
-}: {
- providerId: string;
-}): StickerProvider {
- const provider = providers.get(providerId);
- if (!provider) {
- throw new Error(`Unknown sticker provider: ${providerId}`);
+export class StickersRegistry extends DefinitionRegistry<string, StickerProvider> {
+ constructor() {
+ super("sticker provider");
}
- return provider;
}
-export function getAllProviders(): StickerProvider[] {
- return Array.from(providers.values());
-}
+export const stickersRegistry = new StickersRegistry();
diff --git a/apps/web/src/lib/stickers/resolver.ts b/apps/web/src/lib/stickers/resolver.ts
index 3252cd60..0002e716 100644
--- a/apps/web/src/lib/stickers/resolver.ts
+++ b/apps/web/src/lib/stickers/resolver.ts
@@ -1,7 +1,7 @@
-import { getProvider } from "./registry";
+import { stickersRegistry } from "./registry";
import { parseStickerId } from "./sticker-id";
import { registerDefaultStickerProviders } from "./providers";
-import type { StickerResolveOptions } from "@/types/stickers";
+import type { StickerResolveOptions } from "@/lib/stickers/types";
export function resolveStickerId({
stickerId,
@@ -13,9 +13,7 @@ export function resolveStickerId({
registerDefaultStickerProviders();
const parsedStickerId = parseStickerId({ stickerId });
- return getProvider({
- providerId: parsedStickerId.providerId,
- }).resolveUrl({
+ return stickersRegistry.get(parsedStickerId.providerId).resolveUrl({
stickerId,
options,
});
diff --git a/apps/web/src/lib/stickers/types.ts b/apps/web/src/lib/stickers/types.ts
index 91cbcaae..9d16e4be 100644
--- a/apps/web/src/lib/stickers/types.ts
+++ b/apps/web/src/lib/stickers/types.ts
@@ -1,8 +1,71 @@
-export type {
- StickerItem,
- StickerProvider,
- StickerProviderBrowseOptions,
- StickerProviderSearchOptions,
- StickerResolveOptions,
- StickerSearchResult,
-} from "@/types/stickers";
+import type { STICKER_CATEGORIES } from "@/constants/sticker-constants";
+
+export type StickerCategory = keyof typeof STICKER_CATEGORIES;
+
+export interface StickerItem {
+ id: string;
+ provider: string;
+ name: string;
+ previewUrl: string;
+ metadata: Record<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/text/layout.ts b/apps/web/src/lib/text/layout.ts
index 3eadcec2..32d0d80e 100644
--- a/apps/web/src/lib/text/layout.ts
+++ b/apps/web/src/lib/text/layout.ts
@@ -1,5 +1,5 @@
-import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants";
-import type { TextBackground, TextElement } from "@/types/timeline";
+import type { TextBackground, TextElement } from "@/lib/timeline";
+import { DEFAULTS } from "@/lib/timeline/defaults";
type TextRect = {
left: number;
@@ -14,6 +14,23 @@ export interface TextBlockMeasurement {
maxWidth: number;
}
+type CanvasContext =
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D;
+
+export function setCanvasLetterSpacing({
+ ctx,
+ letterSpacingPx,
+}: {
+ ctx: CanvasContext;
+ letterSpacingPx: number;
+}): void {
+ if ("letterSpacing" in ctx) {
+ (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
+ `${letterSpacingPx}px`;
+ }
+}
+
export function getMetricAscent({
metrics,
fallbackFontSize,
@@ -118,11 +135,11 @@ export function getTextBackgroundRect({
const textRect = getTextRect({ textAlign, block });
const paddingX =
- (background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) * fontSizeRatio;
+ (background.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio;
const paddingY =
- (background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY) * fontSizeRatio;
- const offsetX = background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX;
- const offsetY = background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY;
+ (background.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio;
+ const offsetX = background.offsetX ?? DEFAULTS.text.background.offsetX;
+ const offsetY = background.offsetY ?? DEFAULTS.text.background.offsetY;
return {
left: textRect.left - paddingX + offsetX,
diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts
new file mode 100644
index 00000000..44b2b1ff
--- /dev/null
+++ b/apps/web/src/lib/text/measure-element.ts
@@ -0,0 +1,132 @@
+import {
+ CORNER_RADIUS_MIN,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+import { resolveNumberAtTime } from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import type { TextBackground, TextElement } from "@/lib/timeline";
+import {
+ measureTextBlock,
+ setCanvasLetterSpacing,
+ getTextVisualRect,
+ type TextBlockMeasurement,
+} from "./layout";
+
+export interface ResolvedTextBackground extends TextBackground {
+ paddingX: number;
+ paddingY: number;
+ offsetX: number;
+ offsetY: number;
+ cornerRadius: number;
+}
+
+export interface MeasuredTextElement {
+ scaledFontSize: number;
+ fontString: string;
+ letterSpacing: number;
+ lineHeightPx: number;
+ lines: string[];
+ lineMetrics: TextMetrics[];
+ block: TextBlockMeasurement;
+ fontSizeRatio: number;
+ resolvedBackground: ResolvedTextBackground;
+ visualRect: { left: number; top: number; width: number; height: number };
+}
+
+/**
+ * Shared text measurement used by both the renderer and preview bounds.
+ * Accepts the canvas context to measure on so callers can reuse an existing
+ * context (e.g. the renderer's) rather than creating a throwaway canvas.
+ * The context state is preserved via save/restore.
+ */
+export function measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+}: {
+ element: TextElement;
+ canvasHeight: number;
+ localTime: number;
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+}): MeasuredTextElement {
+ const scaledFontSize =
+ element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
+ const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
+ const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
+ const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
+ const letterSpacing = element.letterSpacing ?? 0;
+ const lineHeightPx =
+ scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
+ const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
+ const lines = element.content.split("\n");
+
+ ctx.save();
+ ctx.font = fontString;
+ ctx.textBaseline = "middle";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
+ const lineMetrics = lines.map((line) => ctx.measureText(line));
+ ctx.restore();
+
+ const block = measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize: scaledFontSize,
+ });
+
+ const bg = element.background;
+ const resolvedBackground: ResolvedTextBackground = {
+ ...bg,
+ paddingX: resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ }),
+ paddingY: resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ }),
+ offsetX: resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ }),
+ offsetY: resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ }),
+ cornerRadius: resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ }),
+ };
+
+ const visualRect = getTextVisualRect({
+ textAlign: element.textAlign,
+ block,
+ background: resolvedBackground,
+ fontSizeRatio,
+ });
+
+ return {
+ scaledFontSize,
+ fontString,
+ letterSpacing,
+ lineHeightPx,
+ lines,
+ lineMetrics,
+ block,
+ fontSizeRatio,
+ resolvedBackground,
+ visualRect,
+ };
+}
diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts
index b6761b0d..a6602566 100644
--- a/apps/web/src/lib/time.ts
+++ b/apps/web/src/lib/time.ts
@@ -1,4 +1,8 @@
-import type { TTimeCode } from "@/types/time";
+export type TTimeCode =
+ | "MM:SS"
+ | "HH:MM:SS"
+ | "HH:MM:SS:CS"
+ | "HH:MM:SS:FF";
export function roundToFrame({
time,
diff --git a/apps/web/src/lib/timeline/audio-constants.ts b/apps/web/src/lib/timeline/audio-constants.ts
new file mode 100644
index 00000000..44592df4
--- /dev/null
+++ b/apps/web/src/lib/timeline/audio-constants.ts
@@ -0,0 +1,2 @@
+export const VOLUME_DB_MIN = -60;
+export const VOLUME_DB_MAX = 20;
diff --git a/apps/web/src/lib/timeline/audio-state.ts b/apps/web/src/lib/timeline/audio-state.ts
new file mode 100644
index 00000000..90d34668
--- /dev/null
+++ b/apps/web/src/lib/timeline/audio-state.ts
@@ -0,0 +1,100 @@
+import { hasKeyframesForPath } from "@/lib/animation/keyframe-query";
+import { resolveNumberAtTime } from "@/lib/animation/resolve";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
+import type { TimelineElement } from "./types";
+const DEFAULT_STEP_SECONDS = 1 / 60;
+
+export type AudioCapableElement = Extract<
+ TimelineElement,
+ { type: "audio" | "video" }
+>;
+
+export function clampDb(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+
+ return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value));
+}
+
+export function dBToLinear(db: number): number {
+ return Math.pow(10, clampDb(db) / 20);
+}
+
+export function hasAnimatedVolume({
+ element,
+}: {
+ element: AudioCapableElement;
+}): boolean {
+ return hasKeyframesForPath({
+ animations: element.animations,
+ propertyPath: "volume",
+ });
+}
+
+export function resolveEffectiveAudioGain({
+ element,
+ trackMuted = false,
+ localTime,
+}: {
+ element: AudioCapableElement;
+ trackMuted?: boolean;
+ localTime: number;
+}): number {
+ if (trackMuted || element.muted === true) {
+ return 0;
+ }
+
+ const resolvedDb = resolveNumberAtTime({
+ baseValue: element.volume ?? 0,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+
+ return dBToLinear(resolvedDb);
+}
+
+export function buildAudioGainAutomation({
+ element,
+ trackMuted = false,
+ fromLocalTime,
+ toLocalTime,
+ stepSeconds = DEFAULT_STEP_SECONDS,
+}: {
+ element: AudioCapableElement;
+ trackMuted?: boolean;
+ fromLocalTime: number;
+ toLocalTime: number;
+ stepSeconds?: number;
+}): Array<{ localTime: number; gain: number }> {
+ const startTime = Math.max(0, fromLocalTime);
+ const endTime = Math.max(startTime, toLocalTime);
+ const safeStep =
+ Number.isFinite(stepSeconds) && stepSeconds > 0
+ ? stepSeconds
+ : DEFAULT_STEP_SECONDS;
+ const points: Array<{ localTime: number; gain: number }> = [];
+
+ for (let localTime = startTime; localTime < endTime; localTime += safeStep) {
+ points.push({
+ localTime,
+ gain: resolveEffectiveAudioGain({
+ element,
+ trackMuted,
+ localTime,
+ }),
+ });
+ }
+
+ points.push({
+ localTime: endTime,
+ gain: resolveEffectiveAudioGain({
+ element,
+ trackMuted,
+ localTime: endTime,
+ }),
+ });
+
+ return points;
+}
diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts
index efaa8f74..f5c034c7 100644
--- a/apps/web/src/lib/timeline/bookmarks.ts
+++ b/apps/web/src/lib/timeline/bookmarks.ts
@@ -1,4 +1,4 @@
-import type { Bookmark } from "@/types/timeline";
+import type { Bookmark } from "@/lib/timeline";
import { roundToFrame } from "@/lib/time";
export const BOOKMARK_TIME_EPSILON = 0.001;
diff --git a/apps/web/src/lib/timeline/defaults.ts b/apps/web/src/lib/timeline/defaults.ts
new file mode 100644
index 00000000..7f52905f
--- /dev/null
+++ b/apps/web/src/lib/timeline/defaults.ts
@@ -0,0 +1,77 @@
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { TTimelineViewState } from "@/lib/project/types";
+import type { BlendMode, Transform } from "@/lib/rendering";
+import type { TextElement } from "./types";
+
+const defaultTransform: Transform = {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+};
+
+const defaultOpacity = 1;
+const defaultBlendMode: BlendMode = "normal";
+const defaultVolume = 0;
+
+const defaultTextLetterSpacing = 0;
+const defaultTextLineHeight = 1.2;
+
+const defaultTextBackground = {
+ enabled: false,
+ color: "#000000",
+ cornerRadius: 0,
+ paddingX: 30,
+ paddingY: 42,
+ offsetX: 0,
+ offsetY: 0,
+};
+
+const defaultTextElement: Omit<TextElement, "id"> = {
+ type: "text",
+ name: "Text",
+ content: "Default text",
+ fontSize: 15,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ background: { ...defaultTextBackground },
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: defaultTextLetterSpacing,
+ lineHeight: defaultTextLineHeight,
+ duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...defaultTransform,
+ position: { ...defaultTransform.position },
+ },
+ opacity: defaultOpacity,
+};
+
+const defaultTimelineViewState: TTimelineViewState = {
+ zoomLevel: 1,
+ scrollLeft: 0,
+ playheadTime: 0,
+};
+
+export const DEFAULTS = {
+ element: {
+ transform: defaultTransform,
+ opacity: defaultOpacity,
+ blendMode: defaultBlendMode,
+ volume: defaultVolume,
+ },
+ text: {
+ letterSpacing: defaultTextLetterSpacing,
+ lineHeight: defaultTextLineHeight,
+ background: defaultTextBackground,
+ element: defaultTextElement,
+ },
+ timeline: {
+ viewState: defaultTimelineViewState,
+ },
+};
diff --git a/apps/web/src/types/drag.ts b/apps/web/src/lib/timeline/drag.ts
index 4e10d767..89b1593f 100644
--- a/apps/web/src/types/drag.ts
+++ b/apps/web/src/lib/timeline/drag.ts
@@ -1,4 +1,5 @@
-import type { VisualElement } from "./timeline";
+import type { MaskableElement, VisualElement } from "./types";
+import type { ParamValues } from "@/lib/params";
interface BaseDragData {
id: string;
@@ -8,7 +9,7 @@ interface BaseDragData {
export interface MediaDragData extends BaseDragData {
type: "media";
mediaType: "image" | "video" | "audio";
- targetElementTypes?: ("video" | "image")[];
+ targetElementTypes?: MaskableElement["type"][];
}
export interface TextDragData extends BaseDragData {
@@ -21,6 +22,12 @@ export interface StickerDragData extends BaseDragData {
stickerId: string;
}
+export interface GraphicDragData extends BaseDragData {
+ type: "graphic";
+ definitionId: string;
+ params: Partial<ParamValues>;
+}
+
export interface EffectDragData extends BaseDragData {
type: "effect";
effectType: string;
@@ -31,4 +38,5 @@ export type TimelineDragData =
| MediaDragData
| TextDragData
| StickerDragData
+ | GraphicDragData
| EffectDragData;
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts
index b502974d..b4f53948 100644
--- a/apps/web/src/lib/timeline/drop-utils.ts
+++ b/apps/web/src/lib/timeline/drop-utils.ts
@@ -2,10 +2,10 @@ import type {
TimelineTrack,
ElementType,
TimelineElement,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
-import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
+import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
import { isMainTrack, enforceMainTrackStart } from "./track-utils";
function findElementAtPosition({
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index 11564376..c44ae044 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -1,30 +1,33 @@
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
- DEFAULT_BLEND_MODE,
- DEFAULT_OPACITY,
- DEFAULT_TRANSFORM,
- TIMELINE_CONSTANTS,
-} from "@/constants/timeline-constants";
-import type {
- CreateEffectElement,
- CreateTimelineElement,
- CreateVideoElement,
- CreateImageElement,
- CreateStickerElement,
- CreateUploadAudioElement,
- CreateLibraryAudioElement,
- TextBackground,
- TextElement,
- TimelineElement,
- TimelineTrack,
- AudioElement,
- VideoElement,
- ImageElement,
- VisualElement,
- UploadAudioElement,
-} from "@/types/timeline";
-import type { MediaType } from "@/types/assets";
+ MASKABLE_ELEMENT_TYPES,
+ RETIMABLE_ELEMENT_TYPES,
+ VISUAL_ELEMENT_TYPES,
+ type CreateEffectElement,
+ type CreateGraphicElement,
+ type CreateTimelineElement,
+ type CreateVideoElement,
+ type CreateImageElement,
+ type CreateStickerElement,
+ type CreateUploadAudioElement,
+ type CreateLibraryAudioElement,
+ type TextBackground,
+ type TextElement,
+ type TimelineElement,
+ type TimelineTrack,
+ type AudioElement,
+ type VideoElement,
+ type ImageElement,
+ type MaskableElement,
+ type RetimableElement,
+ type VisualElement,
+ type UploadAudioElement,
+} from "@/lib/timeline";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import type { MediaType } from "@/lib/media/types";
import { buildDefaultEffectInstance } from "@/lib/effects";
+import { buildDefaultGraphicInstance } from "@/lib/graphics";
+import type { ParamValues } from "@/lib/params";
import { capitalizeFirstLetter } from "@/utils/string";
export function canElementHaveAudio(
@@ -36,12 +39,19 @@ export function canElementHaveAudio(
export function isVisualElement(
element: TimelineElement,
): element is VisualElement {
- return (
- element.type === "video" ||
- element.type === "image" ||
- element.type === "text" ||
- element.type === "sticker"
- );
+ return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function isMaskableElement(
+ element: TimelineElement,
+): element is MaskableElement {
+ return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function isRetimableElement(
+ element: TimelineElement,
+): element is RetimableElement {
+ return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
}
export function canElementBeHidden(
@@ -50,6 +60,14 @@ export function canElementBeHidden(
return isVisualElement(element);
}
+export function hasElementEffects({
+ element,
+}: {
+ element: TimelineElement;
+}): boolean {
+ return isVisualElement(element) && (element.effects?.length ?? 0) > 0;
+}
+
export function hasMediaId(
element: TimelineElement,
): element is UploadAudioElement | VideoElement | ImageElement {
@@ -68,55 +86,6 @@ export function requiresMediaId({
);
}
-export function checkElementOverlaps({
- elements,
-}: {
- elements: TimelineElement[];
-}): boolean {
- const sortedElements = [...elements].sort(
- (a, b) => a.startTime - b.startTime,
- );
-
- for (let i = 0; i < sortedElements.length - 1; i++) {
- const current = sortedElements[i];
- const next = sortedElements[i + 1];
-
- const currentEnd = current.startTime + current.duration;
-
- if (currentEnd > next.startTime) return true;
- }
-
- return false;
-}
-
-export function resolveElementOverlaps({
- elements,
-}: {
- elements: TimelineElement[];
-}): TimelineElement[] {
- const sortedElements = [...elements].sort(
- (a, b) => a.startTime - b.startTime,
- );
- const resolvedElements: TimelineElement[] = [];
-
- for (let i = 0; i < sortedElements.length; i++) {
- const current = { ...sortedElements[i] };
-
- if (resolvedElements.length > 0) {
- const previous = resolvedElements[resolvedElements.length - 1];
- const previousEnd = previous.startTime + previous.duration;
-
- if (current.startTime < previousEnd) {
- current.startTime = previousEnd;
- }
- }
-
- resolvedElements.push(current);
- }
-
- return resolvedElements;
-}
-
export function wouldElementOverlap({
elements,
startTime,
@@ -138,9 +107,8 @@ export function wouldElementOverlap({
function buildTextBackground(
raw: Partial<TextBackground> | undefined,
): TextBackground {
- const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color;
- const enabled =
- typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent";
+ const color = raw?.color ?? DEFAULTS.text.element.background.color;
+ const enabled = raw?.enabled ?? color !== "transparent";
return {
enabled,
color,
@@ -163,28 +131,25 @@ export function buildTextElement({
return {
type: "text",
- name: t.name ?? DEFAULT_TEXT_ELEMENT.name,
- content: t.content ?? DEFAULT_TEXT_ELEMENT.content,
+ name: t.name ?? DEFAULTS.text.element.name,
+ content: t.content ?? DEFAULTS.text.element.content,
duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
- fontSize:
- typeof t.fontSize === "number"
- ? t.fontSize
- : DEFAULT_TEXT_ELEMENT.fontSize,
- fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
- color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
+ fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
+ fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
+ color: t.color ?? DEFAULTS.text.element.color,
background: buildTextBackground(t.background),
- textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
- fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
- fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
- textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration,
- letterSpacing: t.letterSpacing ?? DEFAULT_TEXT_ELEMENT.letterSpacing,
- lineHeight: t.lineHeight ?? DEFAULT_TEXT_ELEMENT.lineHeight,
- transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform,
- opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity,
- blendMode: t.blendMode ?? DEFAULT_BLEND_MODE,
+ textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
+ fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
+ fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
+ textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
+ letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
+ lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
+ transform: t.transform ?? DEFAULTS.text.element.transform,
+ opacity: t.opacity ?? DEFAULTS.text.element.opacity,
+ blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
};
}
@@ -214,10 +179,14 @@ export function buildStickerElement({
stickerId,
name,
startTime,
+ intrinsicWidth,
+ intrinsicHeight,
}: {
stickerId: string;
name?: string;
startTime: number;
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
}): CreateStickerElement {
const stickerNameFromId =
stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
@@ -225,17 +194,52 @@ export function buildStickerElement({
type: "sticker",
name: name ?? stickerNameFromId,
stickerId,
+ intrinsicWidth,
+ intrinsicHeight,
+ duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+export function buildGraphicElement({
+ definitionId,
+ name,
+ startTime,
+ params,
+}: {
+ definitionId: string;
+ name?: string;
+ startTime: number;
+ params?: Partial<ParamValues>;
+}): CreateGraphicElement {
+ const instance = buildDefaultGraphicInstance({ definitionId });
+ return {
+ type: "graphic",
+ name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
+ definitionId: instance.definitionId,
+ params: { ...instance.params, ...(params ?? {}) } as ParamValues,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
- transform: { ...DEFAULT_TRANSFORM },
- opacity: DEFAULT_OPACITY,
- blendMode: DEFAULT_BLEND_MODE,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
};
}
-export function buildVideoElement({
+function buildVideoElement({
mediaId,
name,
duration,
@@ -257,13 +261,17 @@ export function buildVideoElement({
sourceDuration: duration,
muted: false,
hidden: false,
- transform: { ...DEFAULT_TRANSFORM },
- opacity: DEFAULT_OPACITY,
- blendMode: DEFAULT_BLEND_MODE,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ volume: DEFAULTS.element.volume,
};
}
-export function buildImageElement({
+function buildImageElement({
mediaId,
name,
duration,
@@ -283,13 +291,16 @@ export function buildImageElement({
trimStart: 0,
trimEnd: 0,
hidden: false,
- transform: { ...DEFAULT_TRANSFORM },
- opacity: DEFAULT_OPACITY,
- blendMode: DEFAULT_BLEND_MODE,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
};
}
-export function buildUploadAudioElement({
+function buildUploadAudioElement({
mediaId,
name,
duration,
@@ -312,7 +323,7 @@ export function buildUploadAudioElement({
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
- volume: 1,
+ volume: DEFAULTS.element.volume,
muted: false,
};
if (buffer) {
@@ -375,7 +386,7 @@ export function buildLibraryAudioElement({
trimStart: 0,
trimEnd: 0,
sourceDuration: duration,
- volume: 1,
+ volume: DEFAULTS.element.volume,
muted: false,
};
if (buffer) {
@@ -407,7 +418,7 @@ export function getElementsAtTime({
return result;
}
-export function collectFontFamilies({
+export function getElementFontFamilies({
tracks,
}: {
tracks: TimelineTrack[];
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts
index 887ff7ab..29414722 100644
--- a/apps/web/src/lib/timeline/index.ts
+++ b/apps/web/src/lib/timeline/index.ts
@@ -1,5 +1,7 @@
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "./types";
+export * from "./types";
+export * from "./drag";
export * from "./track-utils";
export * from "./track-element-update";
export * from "./element-utils";
diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts
index 5f37bf29..369759f3 100644
--- a/apps/web/src/lib/timeline/ripple-utils.ts
+++ b/apps/web/src/lib/timeline/ripple-utils.ts
@@ -1,4 +1,4 @@
-import type { TimelineElement } from "@/types/timeline";
+import type { TimelineElement } from "@/lib/timeline";
export function rippleShiftElements({
elements,
diff --git a/apps/web/src/lib/timeline/selection-hit-testing.ts b/apps/web/src/lib/timeline/selection-hit-testing.ts
new file mode 100644
index 00000000..ac527600
--- /dev/null
+++ b/apps/web/src/lib/timeline/selection-hit-testing.ts
@@ -0,0 +1,133 @@
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
+
+type TimelineElementRef = { trackId: string; elementId: string };
+
+interface SelectionRectangle {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+function getNormalizedRectangle({
+ startPos,
+ endPos,
+}: {
+ startPos: { x: number; y: number };
+ endPos: { x: number; y: number };
+}): SelectionRectangle {
+ return {
+ left: Math.min(startPos.x, endPos.x),
+ top: Math.min(startPos.y, endPos.y),
+ right: Math.max(startPos.x, endPos.x),
+ bottom: Math.max(startPos.y, endPos.y),
+ };
+}
+
+function getSelectionRectangleInContent({
+ container,
+ scrollContainer,
+ startPos,
+ endPos,
+}: {
+ container: HTMLElement;
+ scrollContainer: HTMLDivElement | null;
+ startPos: { x: number; y: number };
+ endPos: { x: number; y: number };
+}): SelectionRectangle {
+ const containerRect = container.getBoundingClientRect();
+ const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+
+ const adjustedStart = {
+ x: startPos.x - containerRect.left + scrollLeft,
+ y: startPos.y - scrollRect.top + scrollTop,
+ };
+ const adjustedEnd = {
+ x: endPos.x - containerRect.left + scrollLeft,
+ y: endPos.y - scrollRect.top + scrollTop,
+ };
+
+ return getNormalizedRectangle({
+ startPos: adjustedStart,
+ endPos: adjustedEnd,
+ });
+}
+
+function isRectangleIntersecting({
+ elementRectangle,
+ selectionRectangle,
+}: {
+ elementRectangle: SelectionRectangle;
+ selectionRectangle: SelectionRectangle;
+}): boolean {
+ return !(
+ elementRectangle.right < selectionRectangle.left ||
+ elementRectangle.left > selectionRectangle.right ||
+ elementRectangle.bottom < selectionRectangle.top ||
+ elementRectangle.top > selectionRectangle.bottom
+ );
+}
+
+export function resolveTimelineElementIntersections({
+ container,
+ scrollContainer,
+ tracks,
+ zoomLevel,
+ startPos,
+ currentPos,
+}: {
+ container: HTMLElement;
+ scrollContainer: HTMLDivElement | null;
+ tracks: TimelineTrack[];
+ zoomLevel: number;
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+}): TimelineElementRef[] {
+ const selectionRectangle = getSelectionRectangleInContent({
+ container,
+ scrollContainer,
+ startPos,
+ endPos: currentPos,
+ });
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const selectedElements: TimelineElementRef[] = [];
+
+ for (const [trackIndex, track] of tracks.entries()) {
+ const trackTop = getCumulativeHeightBefore({
+ tracks,
+ trackIndex,
+ });
+ const trackHeight = getTrackHeight({ type: track.type });
+ const elementTop = TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
+ const elementBottom = elementTop + trackHeight;
+
+ for (const element of track.elements) {
+ const elementLeft = element.startTime * pixelsPerSecond;
+ const elementRight = elementLeft + element.duration * pixelsPerSecond;
+ const elementRectangle = {
+ left: elementLeft,
+ top: elementTop,
+ right: elementRight,
+ bottom: elementBottom,
+ };
+
+ if (
+ isRectangleIntersecting({
+ elementRectangle,
+ selectionRectangle,
+ })
+ ) {
+ selectedElements.push({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ }
+ }
+ }
+
+ return selectedElements;
+}
diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts
index c7fac090..ce2e9b1f 100644
--- a/apps/web/src/lib/timeline/snap-utils.ts
+++ b/apps/web/src/lib/timeline/snap-utils.ts
@@ -1,4 +1,4 @@
-import type { Bookmark, TimelineTrack } from "@/types/timeline";
+import type { Bookmark, TimelineTrack } from "@/lib/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
import { getElementKeyframes } from "@/lib/animation";
diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts
index 6118b327..5230d4e7 100644
--- a/apps/web/src/lib/timeline/track-element-update.ts
+++ b/apps/web/src/lib/timeline/track-element-update.ts
@@ -1,4 +1,4 @@
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
export function updateElementInTracks({
tracks,
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts
index 94ff21ba..cde2c814 100644
--- a/apps/web/src/lib/timeline/track-utils.ts
+++ b/apps/web/src/lib/timeline/track-utils.ts
@@ -4,13 +4,15 @@ import type {
ElementType,
VideoTrack,
AudioTrack,
- StickerTrack,
+ GraphicTrack,
TextTrack,
EffectTrack,
TimelineElement,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import {
+ ELEMENT_TRACK_MAP,
TRACK_CONFIG,
+ ELEMENT_TYPE_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
import { generateUUID } from "@/utils/id";
@@ -23,16 +25,12 @@ export function canTracktHaveAudio(
export function canTrackBeHidden(
track: TimelineTrack,
-): track is VideoTrack | TextTrack | StickerTrack | EffectTrack {
+): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
return track.type !== "audio";
}
-export function getTrackColor({ type }: { type: TrackType }) {
- return TRACK_CONFIG[type];
-}
-
-export function getTrackClasses({ type }: { type: TrackType }) {
- return TRACK_CONFIG[type].background.trim();
+export function getElementClasses({ type }: { type: TrackType }) {
+ return ELEMENT_TYPE_CONFIG[type].background.trim();
}
export function getTrackHeight({ type }: { type: TrackType }): number {
@@ -97,11 +95,11 @@ export function buildEmptyTrack({
elements: [],
hidden: false,
};
- case "sticker":
+ case "graphic":
return {
id,
name: trackName,
- type: "sticker",
+ type: "graphic",
elements: [],
hidden: false,
};
@@ -214,14 +212,7 @@ export function canElementGoOnTrack({
elementType: ElementType;
trackType: TrackType;
}): boolean {
- if (elementType === "text") return trackType === "text";
- if (elementType === "audio") return trackType === "audio";
- if (elementType === "sticker") return trackType === "sticker";
- if (elementType === "effect") return trackType === "effect";
- if (elementType === "video" || elementType === "image") {
- return trackType === "video";
- }
- return false;
+ return ELEMENT_TRACK_MAP[elementType] === trackType;
}
export function validateElementTrackCompatibility({
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/lib/timeline/types.ts
index 15696b34..16e06bf1 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/lib/timeline/types.ts
@@ -1,6 +1,13 @@
-import type { ElementAnimations } from "./animation";
-import type { Effect, EffectParamValues } from "./effects";
-import type { BlendMode, Transform } from "./rendering";
+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;
@@ -19,7 +26,7 @@ export interface TScene {
updatedAt: Date;
}
-export type TrackType = "video" | "text" | "audio" | "sticker" | "effect";
+export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
interface BaseTrack {
id: string;
@@ -46,9 +53,9 @@ export interface AudioTrack extends BaseTrack {
muted: boolean;
}
-export interface StickerTrack extends BaseTrack {
- type: "sticker";
- elements: StickerElement[];
+export interface GraphicTrack extends BaseTrack {
+ type: "graphic";
+ elements: (StickerElement | GraphicElement)[];
hidden: boolean;
}
@@ -62,16 +69,20 @@ export type TimelineTrack =
| VideoTrack
| TextTrack
| AudioTrack
- | StickerTrack
+ | GraphicTrack
| EffectTrack;
-export type { Transform } from "./rendering";
+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 {
@@ -100,12 +111,15 @@ interface BaseTimelineElement {
export interface VideoElement extends BaseTimelineElement {
type: "video";
mediaId: string;
+ volume?: number;
muted?: boolean;
hidden?: boolean;
+ retime?: RetimeConfig;
transform: Transform;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[];
+ masks?: Mask[];
}
export interface ImageElement extends BaseTimelineElement {
@@ -116,6 +130,7 @@ export interface ImageElement extends BaseTimelineElement {
opacity: number;
blendMode?: BlendMode;
effects?: Effect[];
+ masks?: Mask[];
}
export interface TextBackground {
@@ -151,25 +166,34 @@ export interface TextElement extends BaseTimelineElement {
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: EffectParamValues;
+ params: ParamValues;
}
-export type VisualElement =
- | VideoElement
- | ImageElement
- | TextElement
- | StickerElement;
-
export type ElementUpdatePatch =
| { transform: Transform }
| { opacity: number }
@@ -181,10 +205,42 @@ export type TimelineElement =
| 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 =
@@ -194,6 +250,7 @@ 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
@@ -201,6 +258,7 @@ export type CreateTimelineElement =
| CreateImageElement
| CreateTextElement
| CreateStickerElement
+ | CreateGraphicElement
| CreateEffectElement;
export interface ElementDragState {
diff --git a/apps/web/src/lib/transcription/caption.ts b/apps/web/src/lib/transcription/caption.ts
index c773b7b0..c7c1b7b4 100644
--- a/apps/web/src/lib/transcription/caption.ts
+++ b/apps/web/src/lib/transcription/caption.ts
@@ -1,4 +1,4 @@
-import type { TranscriptionSegment, CaptionChunk } from "@/types/transcription";
+import type { TranscriptionSegment, CaptionChunk } from "@/lib/transcription/types";
import {
DEFAULT_WORDS_PER_CAPTION,
MIN_CAPTION_DURATION_SECONDS,
diff --git a/apps/web/src/types/transcription.ts b/apps/web/src/lib/transcription/types.ts
index f189c6c9..9724ad70 100644
--- a/apps/web/src/types/transcription.ts
+++ b/apps/web/src/lib/transcription/types.ts
@@ -1,4 +1,4 @@
-import type { LanguageCode } from "./language";
+import type { LanguageCode } from "@/constants/language-constants";
export type TranscriptionLanguage = LanguageCode | "auto";
diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts
index 2cebec5d..b2f6f51a 100644
--- a/apps/web/src/services/renderer/effect-preview.ts
+++ b/apps/web/src/services/renderer/effect-preview.ts
@@ -1,194 +1,191 @@
import { createOffscreenCanvas } from "./canvas-utils";
-import { getEffect } from "@/lib/effects";
-import type { EffectParamValues } from "@/types/effects";
-import { applyMultiPassEffect } from "./webgl-utils";
-import type { EffectPassData } from "./webgl-utils";
+import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
+import { buildDefaultParamValues } from "@/lib/registry";
+import type { ParamValues } from "@/lib/params";
+import { applyMultiPassEffect } from "./webgl/webgl-utils";
+import type { EffectPassData } from "./webgl/webgl-utils";
const PREVIEW_SIZE = 160;
const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
-let previewGl: WebGLRenderingContext | null = null;
-let previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
-let testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
-let previewImageElement: HTMLImageElement | null = null;
-const programCache = new Map<string, WebGLProgram>();
-const onReadyCallbacks = new Set<() => void>();
+class EffectPreviewService {
+ private previewGl: WebGLRenderingContext | null = null;
+ private previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+ private testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+ private previewImageElement: HTMLImageElement | null = null;
+ private programCache = new Map<string, WebGLProgram>();
+ private onReadyCallbacks = new Set<() => void>();
-export function onPreviewImageReady({
- callback,
-}: {
- callback: () => void;
-}): () => void {
- onReadyCallbacks.add(callback);
- return () => onReadyCallbacks.delete(callback);
-}
-
-function loadPreviewImage(): void {
- if (typeof window === "undefined") return;
- const image = new Image();
- image.onload = () => {
- testSourceCanvas = null;
- for (const callback of onReadyCallbacks) {
- callback();
- }
- };
- image.src = PREVIEW_IMAGE_PATH;
- previewImageElement = image;
-}
+ readonly PREVIEW_SIZE = PREVIEW_SIZE;
-loadPreviewImage();
-
-function buildDefaultParams({
- effectType,
-}: {
- effectType: string;
-}): EffectParamValues {
- const definition = getEffect({ effectType });
- const params: EffectParamValues = {};
- for (const paramDef of definition.params) {
- params[paramDef.key] = paramDef.default;
+ constructor() {
+ this.loadPreviewImage();
}
- return params;
-}
-function createTestSource({
- width,
- height,
-}: {
- width: number;
- height: number;
-}): OffscreenCanvas | HTMLCanvasElement | null {
- const isImageReady =
- previewImageElement?.complete &&
- (previewImageElement.naturalWidth ?? 0) > 0;
- if (!isImageReady || !previewImageElement) {
- return null;
+ onPreviewImageReady({
+ callback,
+ }: {
+ callback: () => void;
+ }): () => void {
+ this.onReadyCallbacks.add(callback);
+ return () => this.onReadyCallbacks.delete(callback);
}
- const canvas = createOffscreenCanvas({ width, height });
- const ctx = canvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (!ctx) {
- throw new Error("failed to get 2d context for test source");
- }
- ctx.drawImage(previewImageElement, 0, 0, width, height);
- return canvas;
-}
+ renderPreview({
+ effectType,
+ params,
+ targetCanvas,
+ uniformDimensions,
+ }: {
+ effectType: string;
+ params: ParamValues;
+ targetCanvas: HTMLCanvasElement;
+ uniformDimensions?: { width: number; height: number };
+ }): void {
+ const size = PREVIEW_SIZE;
+ const source = this.getTestSource({ width: size, height: size });
+ if (!source) return;
-function getOrCreatePreviewContext({
- width,
- height,
-}: {
- width: number;
- height: number;
-}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } {
- if (!previewCanvas || !previewGl) {
- previewCanvas = createOffscreenCanvas({ width, height });
- previewGl = previewCanvas.getContext("webgl", {
- premultipliedAlpha: false,
- }) as WebGLRenderingContext | null;
- if (!previewGl) {
- throw new Error("WebGL not supported");
+ const definition = effectsRegistry.get(effectType);
+ const resolvedParams =
+ Object.keys(params).length > 0
+ ? params
+ : buildDefaultParamValues(definition.params);
+
+ const passes = resolveEffectPasses({
+ definition,
+ effectParams: resolvedParams,
+ width: uniformDimensions?.width ?? size,
+ height: uniformDimensions?.height ?? size,
+ });
+ const result = this.applyWebGlEffect({
+ source,
+ width: size,
+ height: size,
+ passes,
+ });
+
+ const targetCtx = targetCanvas.getContext(
+ "2d",
+ ) as CanvasRenderingContext2D | null;
+ if (targetCtx) {
+ targetCanvas.width = size;
+ targetCanvas.height = size;
+ targetCtx.drawImage(result, 0, 0, size, size);
}
}
- if (previewCanvas.width !== width || previewCanvas.height !== height) {
- previewCanvas.width = width;
- previewCanvas.height = height;
- }
- return { canvas: previewCanvas, gl: previewGl };
-}
-function getTestSource({
- width,
- height,
-}: {
- width: number;
- height: number;
-}): CanvasImageSource | null {
- if (
- !testSourceCanvas ||
- testSourceCanvas.width !== width ||
- testSourceCanvas.height !== height
- ) {
- testSourceCanvas = createTestSource({ width, height });
+ private loadPreviewImage(): void {
+ if (typeof window === "undefined") return;
+ const image = new Image();
+ image.onload = () => {
+ this.testSourceCanvas = null;
+ for (const callback of this.onReadyCallbacks) {
+ callback();
+ }
+ };
+ image.src = PREVIEW_IMAGE_PATH;
+ this.previewImageElement = image;
}
- return testSourceCanvas;
-}
-
-function applyWebGlEffect({
- source,
- width,
- height,
- passes,
-}: {
- source: CanvasImageSource;
- width: number;
- height: number;
- passes: EffectPassData[];
-}): OffscreenCanvas | HTMLCanvasElement {
- const { canvas: glCanvas, gl } = getOrCreatePreviewContext({ width, height });
- applyMultiPassEffect({ context: gl, source, width, height, passes, programCache });
+ private createTestSource({
+ width,
+ height,
+ }: {
+ width: number;
+ height: number;
+ }): OffscreenCanvas | HTMLCanvasElement | null {
+ const isImageReady =
+ this.previewImageElement?.complete &&
+ (this.previewImageElement.naturalWidth ?? 0) > 0;
+ if (!isImageReady || !this.previewImageElement) {
+ return null;
+ }
- const outputCanvas = createOffscreenCanvas({ width, height });
- const outputCtx = outputCanvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (outputCtx) {
- outputCtx.drawImage(glCanvas, 0, 0, width, height);
+ const canvas = createOffscreenCanvas({ width, height });
+ const ctx = canvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!ctx) {
+ throw new Error("failed to get 2d context for test source");
+ }
+ ctx.drawImage(this.previewImageElement, 0, 0, width, height);
+ return canvas;
}
- return outputCanvas;
-}
-export function renderPreview({
- effectType,
- params,
- targetCanvas,
-}: {
- effectType: string;
- params: EffectParamValues;
- targetCanvas: HTMLCanvasElement;
-}): void {
- const size = PREVIEW_SIZE;
- const source = getTestSource({ width: size, height: size });
- if (!source) return;
+ private getOrCreatePreviewContext({
+ width,
+ height,
+ }: {
+ width: number;
+ height: number;
+ }): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } {
+ if (!this.previewCanvas || !this.previewGl) {
+ this.previewCanvas = createOffscreenCanvas({ width, height });
+ this.previewGl = this.previewCanvas.getContext("webgl", {
+ premultipliedAlpha: false,
+ }) as WebGLRenderingContext | null;
+ if (!this.previewGl) {
+ throw new Error("WebGL not supported");
+ }
+ }
+ if (this.previewCanvas.width !== width || this.previewCanvas.height !== height) {
+ this.previewCanvas.width = width;
+ this.previewCanvas.height = height;
+ }
+ return { canvas: this.previewCanvas, gl: this.previewGl };
+ }
- const definition = getEffect({ effectType });
- const resolvedParams =
- Object.keys(params).length > 0
- ? params
- : buildDefaultParams({ effectType });
+ private getTestSource({
+ width,
+ height,
+ }: {
+ width: number;
+ height: number;
+ }): CanvasImageSource | null {
+ if (
+ !this.testSourceCanvas ||
+ this.testSourceCanvas.width !== width ||
+ this.testSourceCanvas.height !== height
+ ) {
+ this.testSourceCanvas = this.createTestSource({ width, height });
+ }
+ return this.testSourceCanvas;
+ }
- const passes = definition.renderer.passes.map((pass) => ({
- fragmentShader: pass.fragmentShader,
- uniforms: pass.uniforms({
- effectParams: resolvedParams,
- width: size,
- height: size,
- }),
- }));
- const result = applyWebGlEffect({
+ private applyWebGlEffect({
source,
- width: size,
- height: size,
+ width,
+ height,
passes,
- });
+ }: {
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ passes: EffectPassData[];
+ }): OffscreenCanvas | HTMLCanvasElement {
+ const { canvas: glCanvas, gl } = this.getOrCreatePreviewContext({ width, height });
+
+ applyMultiPassEffect({
+ context: gl,
+ source,
+ width,
+ height,
+ passes,
+ programCache: this.programCache,
+ });
- const targetCtx = targetCanvas.getContext(
- "2d",
- ) as CanvasRenderingContext2D | null;
- if (targetCtx) {
- targetCanvas.width = size;
- targetCanvas.height = size;
- targetCtx.drawImage(result, 0, 0, size, size);
+ const outputCanvas = createOffscreenCanvas({ width, height });
+ const outputCtx = outputCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (outputCtx) {
+ outputCtx.drawImage(glCanvas, 0, 0, width, height);
+ }
+ return outputCanvas;
}
}
-export const effectPreviewService = {
- renderPreview,
- onPreviewImageReady,
- PREVIEW_SIZE,
-};
+export const effectPreviewService = new EffectPreviewService();
diff --git a/apps/web/src/services/renderer/mask-feather.ts b/apps/web/src/services/renderer/mask-feather.ts
new file mode 100644
index 00000000..39ad80a8
--- /dev/null
+++ b/apps/web/src/services/renderer/mask-feather.ts
@@ -0,0 +1,54 @@
+import jfaDistanceShader from "@/lib/masks/shaders/jfa-distance.frag.glsl";
+import { getWebGLContext, readResult } from "./webgl/webgl-context";
+import { computeSignedDistanceField, runPass } from "./webgl/jfa";
+import { compileProgram, createTexture } from "./webgl/webgl-utils";
+
+export function applyMaskFeather({
+ maskCanvas,
+ width,
+ height,
+ feather,
+}: {
+ maskCanvas: CanvasImageSource;
+ width: number;
+ height: number;
+ feather: number;
+}): OffscreenCanvas | HTMLCanvasElement {
+ const { context, programCache } = getWebGLContext({ width, height });
+ const sourceTexture = createTexture({ context, source: maskCanvas });
+
+ const sdf = computeSignedDistanceField({
+ context,
+ programCache,
+ sourceTexture,
+ width,
+ height,
+ });
+
+ const distanceProgram = compileProgram({
+ context,
+ fragmentShaderSource: jfaDistanceShader,
+ programCache,
+ });
+
+ runPass({
+ context,
+ program: distanceProgram,
+ inputTexture: sdf.insideTexture,
+ target: null,
+ width,
+ height,
+ uniforms: { u_feather_half: feather / 2.0 },
+ extraBindings: [
+ { unit: 1, texture: sdf.outsideTexture, name: "u_jfa_outside" },
+ ],
+ });
+
+ context.deleteTexture(sourceTexture);
+ sdf.cleanup();
+
+ context.bindTexture(context.TEXTURE_2D, null);
+ context.bindFramebuffer(context.FRAMEBUFFER, null);
+
+ return readResult({ width, height });
+}
diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts
new file mode 100644
index 00000000..7f4d06fc
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/blur-background-node.ts
@@ -0,0 +1,149 @@
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import { buildGaussianBlurPasses } from "@/lib/effects/definitions/blur";
+import { getSourceTimeAtClipTime } from "@/lib/retime";
+import { videoCache } from "@/services/video-cache/service";
+import type { RetimeConfig } from "@/lib/timeline";
+import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
+import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
+import { BaseNode } from "./base-node";
+import { loadImageSource, type CachedImageSource } from "./image-node";
+
+export type BlurBackgroundNodeParams = {
+ mediaId: string;
+ url: string;
+ file: File;
+ mediaType: "video" | "image";
+ duration: number;
+ timeOffset: number;
+ trimStart: number;
+ trimEnd: number;
+ retime?: RetimeConfig;
+ blurIntensity: number;
+};
+
+type BackdropSource = {
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+};
+
+export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
+ private cachedImageSource: Promise<CachedImageSource> | null;
+
+ constructor(params: BlurBackgroundNodeParams) {
+ super(params);
+ this.cachedImageSource =
+ params.mediaType === "image" ? loadImageSource(params.url) : null;
+ }
+
+ private isInRange({ time }: { time: number }): boolean {
+ const localTime = time - this.params.timeOffset;
+ return (
+ localTime >= -TIME_EPSILON_SECONDS && localTime < this.params.duration
+ );
+ }
+
+ private getSourceLocalTime({ time }: { time: number }): number {
+ const clipTime = time - this.params.timeOffset;
+ return (
+ this.params.trimStart +
+ getSourceTimeAtClipTime({
+ clipTime,
+ retime: this.params.retime,
+ })
+ );
+ }
+
+ private async getBackdropSource({
+ time,
+ }: {
+ time: number;
+ }): Promise<BackdropSource | null> {
+ if (this.params.mediaType === "video") {
+ const frame = await videoCache.getFrameAt({
+ mediaId: this.params.mediaId,
+ file: this.params.file,
+ time: this.getSourceLocalTime({ time }),
+ });
+
+ if (!frame) {
+ return null;
+ }
+
+ return {
+ source: frame.canvas,
+ width: frame.canvas.width,
+ height: frame.canvas.height,
+ };
+ }
+
+ if (!this.cachedImageSource) {
+ return null;
+ }
+
+ const { source, width, height } = await this.cachedImageSource;
+ return { source, width, height };
+ }
+
+ async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
+ await super.render({ renderer, time });
+
+ if (!this.isInRange({ time })) {
+ return;
+ }
+
+ const backdropSource = await this.getBackdropSource({ time });
+ if (!backdropSource) {
+ return;
+ }
+
+ const offscreen = createOffscreenCanvas({
+ width: renderer.width,
+ height: renderer.height,
+ });
+ const offscreenCtx = offscreen.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!offscreenCtx) {
+ return;
+ }
+
+ const coverScale = Math.max(
+ renderer.width / backdropSource.width,
+ renderer.height / backdropSource.height,
+ );
+ const scaledWidth = backdropSource.width * coverScale;
+ const scaledHeight = backdropSource.height * coverScale;
+ const offsetX = (renderer.width - scaledWidth) / 2;
+ const offsetY = (renderer.height - scaledHeight) / 2;
+
+ offscreenCtx.drawImage(
+ backdropSource.source,
+ offsetX,
+ offsetY,
+ scaledWidth,
+ scaledHeight,
+ );
+
+ const passes = buildGaussianBlurPasses({
+ sigmaX: this.params.blurIntensity * (renderer.width / 1920),
+ sigmaY: this.params.blurIntensity * (renderer.height / 1080),
+ });
+ const effectResult = webglEffectRenderer.applyEffect({
+ source: offscreen as CanvasImageSource,
+ width: renderer.width,
+ height: renderer.height,
+ passes,
+ });
+
+ renderer.context.drawImage(
+ effectResult,
+ 0,
+ 0,
+ renderer.width,
+ renderer.height,
+ );
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/composite-effect-node.ts b/apps/web/src/services/renderer/nodes/composite-effect-node.ts
deleted file mode 100644
index 3fbfffc1..00000000
--- a/apps/web/src/services/renderer/nodes/composite-effect-node.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import type { CanvasRenderer } from "../canvas-renderer";
-import { createOffscreenCanvas } from "../canvas-utils";
-import { getEffect } from "@/lib/effects";
-import type { EffectParamValues } from "@/types/effects";
-import { BaseNode } from "./base-node";
-import { webglEffectRenderer } from "../webgl-effect-renderer";
-
-export type CompositeEffectNodeParams = {
- contentNodes: BaseNode[];
- effectType: string;
- effectParams: EffectParamValues;
- scale: number;
-};
-
-export class CompositeEffectNode extends BaseNode<CompositeEffectNodeParams> {
- async render({
- renderer,
- time,
- }: {
- renderer: CanvasRenderer;
- time: number;
- }): Promise<void> {
- const offscreen = createOffscreenCanvas({
- width: renderer.width,
- height: renderer.height,
- });
- const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
- if (!offscreenCtx) {
- throw new Error("failed to get offscreen canvas context");
- }
-
- const originalContext = renderer.context;
- renderer.context = offscreenCtx;
-
- for (const node of this.params.contentNodes) {
- await node.render({ renderer, time });
- }
-
- renderer.context = originalContext;
-
- const effectDefinition = getEffect({ effectType: this.params.effectType });
- const scale = this.params.scale;
- const scaledWidth = renderer.width * scale;
- const scaledHeight = renderer.height * scale;
- const offsetX = (renderer.width - scaledWidth) / 2;
- const offsetY = (renderer.height - scaledHeight) / 2;
-
- const passes = effectDefinition.renderer.passes.map((pass) => ({
- fragmentShader: pass.fragmentShader,
- uniforms: pass.uniforms({
- effectParams: this.params.effectParams,
- width: renderer.width,
- height: renderer.height,
- }),
- }));
- const effectResult = webglEffectRenderer.applyEffect({
- source: offscreen as CanvasImageSource,
- width: renderer.width,
- height: renderer.height,
- passes,
- });
-
- renderer.context.save();
- renderer.context.drawImage(
- effectResult,
- 0,
- 0,
- renderer.width,
- renderer.height,
- offsetX,
- offsetY,
- scaledWidth,
- scaledHeight,
- );
- renderer.context.restore();
- }
-}
diff --git a/apps/web/src/services/renderer/nodes/effect-layer-node.ts b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
index 3d84be2b..82aee1b3 100644
--- a/apps/web/src/services/renderer/nodes/effect-layer-node.ts
+++ b/apps/web/src/services/renderer/nodes/effect-layer-node.ts
@@ -1,14 +1,14 @@
import type { CanvasRenderer } from "../canvas-renderer";
-import { getEffect } from "@/lib/effects";
-import type { EffectParamValues } from "@/types/effects";
+import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
+import type { ParamValues } from "@/lib/params";
import { BaseNode } from "./base-node";
-import { webglEffectRenderer } from "../webgl-effect-renderer";
+import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
const TIME_EPSILON = 1e-6;
export type EffectLayerNodeParams = {
effectType: string;
- effectParams: EffectParamValues;
+ effectParams: ParamValues;
timeOffset: number;
duration: number;
};
@@ -49,18 +49,17 @@ export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> {
const source = renderer.context.canvas as CanvasImageSource;
- const effectDefinition = getEffect({
- effectType: this.params.effectType,
- });
+ const effectDefinition = effectsRegistry.get(this.params.effectType);
- const passes = effectDefinition.renderer.passes.map((pass) => ({
- fragmentShader: pass.fragmentShader,
- uniforms: pass.uniforms({
- effectParams: this.params.effectParams,
- width: renderer.width,
- height: renderer.height,
- }),
- }));
+ const passes = resolveEffectPasses({
+ definition: effectDefinition,
+ effectParams: this.params.effectParams,
+ width: renderer.width,
+ height: renderer.height,
+ });
+ if (passes.length === 0) {
+ return;
+ }
const effectResult = webglEffectRenderer.applyEffect({
source,
width: renderer.width,
diff --git a/apps/web/src/services/renderer/nodes/graphic-node.ts b/apps/web/src/services/renderer/nodes/graphic-node.ts
new file mode 100644
index 00000000..b258a353
--- /dev/null
+++ b/apps/web/src/services/renderer/nodes/graphic-node.ts
@@ -0,0 +1,92 @@
+import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
+import {
+ DEFAULT_GRAPHIC_SOURCE_SIZE,
+ getGraphicDefinition,
+ registerDefaultGraphics,
+} from "@/lib/graphics";
+import { resolveGraphicParamsAtTime } from "@/lib/animation";
+import type { ParamValues } from "@/lib/params";
+import { VisualNode, type VisualNodeParams } from "./visual-node";
+
+export interface GraphicNodeParams extends VisualNodeParams {
+ definitionId: string;
+ params: ParamValues;
+}
+
+export class GraphicNode extends VisualNode<GraphicNodeParams> {
+ private cachedKey: string | null = null;
+ private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null;
+
+ constructor(params: GraphicNodeParams) {
+ super(params);
+ registerDefaultGraphics();
+ }
+
+ private getSource({
+ localTime,
+ }: {
+ localTime: number;
+ }): OffscreenCanvas | HTMLCanvasElement | null {
+ const definition = getGraphicDefinition({
+ definitionId: this.params.definitionId,
+ });
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: this.params,
+ localTime,
+ });
+ const cacheKey = JSON.stringify({
+ definitionId: this.params.definitionId,
+ params: resolvedParams,
+ });
+ if (this.cachedSource && this.cachedKey === cacheKey) {
+ return this.cachedSource;
+ }
+
+ const canvas = createOffscreenCanvas({
+ width: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ height: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ });
+ const ctx = canvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!ctx) {
+ return null;
+ }
+
+ definition.render({
+ ctx,
+ params: resolvedParams,
+ width: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ height: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ });
+
+ this.cachedKey = cacheKey;
+ this.cachedSource = canvas;
+ return canvas;
+ }
+
+ async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
+ await super.render({ renderer, time });
+
+ if (!this.isInRange({ time })) {
+ return;
+ }
+
+ const source = this.getSource({
+ localTime: this.getAnimationLocalTime({ time }),
+ });
+ if (!source) {
+ return;
+ }
+
+ this.renderVisual({
+ renderer,
+ source,
+ sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ timelineTime: time,
+ });
+ }
+}
diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts
index 6720a5e5..7d74f511 100644
--- a/apps/web/src/services/renderer/nodes/image-node.ts
+++ b/apps/web/src/services/renderer/nodes/image-node.ts
@@ -6,7 +6,7 @@ export interface ImageNodeParams extends VisualNodeParams {
maxSourceSize?: number;
}
-interface CachedImageSource {
+export interface CachedImageSource {
source: HTMLImageElement | OffscreenCanvas;
width: number;
height: number;
@@ -14,7 +14,7 @@ interface CachedImageSource {
const imageSourceCache = new Map<string, Promise<CachedImageSource>>();
-function loadImageSource(
+export function loadImageSource(
url: string,
maxSourceSize?: number,
): Promise<CachedImageSource> {
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index da271685..71cb37b5 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -4,6 +4,8 @@ import { VisualNode, type VisualNodeParams } from "./visual-node";
export interface StickerNodeParams extends VisualNodeParams {
stickerId: string;
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
}
interface CachedStickerSource {
@@ -14,7 +16,7 @@ interface CachedStickerSource {
const stickerSourceCache = new Map<string, Promise<CachedStickerSource>>();
-function loadStickerSource(stickerId: string): Promise<CachedStickerSource> {
+function loadStickerSource({ stickerId }: { stickerId: string }): Promise<CachedStickerSource> {
const cached = stickerSourceCache.get(stickerId);
if (cached) return cached;
@@ -33,7 +35,7 @@ function loadStickerSource(stickerId: string): Promise<CachedStickerSource> {
image.src = url;
});
- return { source: image, width: 200, height: 200 };
+ return { source: image, width: image.naturalWidth, height: image.naturalHeight };
})();
stickerSourceCache.set(stickerId, promise);
@@ -45,7 +47,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
constructor(params: StickerNodeParams) {
super(params);
- this.cachedSource = loadStickerSource(params.stickerId);
+ this.cachedSource = loadStickerSource({ stickerId: params.stickerId });
}
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
@@ -55,13 +57,19 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
return;
}
- const { source, width, height } = await this.cachedSource;
+ const { source, width: loadedWidth, height: loadedHeight } =
+ await this.cachedSource;
+
+ // Prefer element-stored intrinsic dimensions as the geometry authority.
+ // The loaded image is only the drawable source.
+ const sourceWidth = this.params.intrinsicWidth ?? loadedWidth;
+ const sourceHeight = this.params.intrinsicHeight ?? loadedHeight;
this.renderVisual({
renderer,
source,
- sourceWidth: width,
- sourceHeight: height,
+ sourceWidth,
+ sourceHeight,
timelineTime: time,
});
}
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index 86e24fdf..031c43b3 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -1,12 +1,8 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { createOffscreenCanvas } from "../canvas-utils";
import { BaseNode } from "./base-node";
-import type { TextElement } from "@/types/timeline";
+import type { TextElement } from "@/lib/timeline";
import {
- DEFAULT_TEXT_BACKGROUND,
- DEFAULT_TEXT_ELEMENT,
- DEFAULT_LINE_HEIGHT,
- FONT_SIZE_SCALE_REFERENCE,
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
} from "@/constants/text-constants";
@@ -14,34 +10,20 @@ import {
getMetricAscent,
getMetricDescent,
getTextBackgroundRect,
- measureTextBlock,
+ setCanvasLetterSpacing,
} from "@/lib/text/layout";
+import { measureTextElement } from "@/lib/text/measure-element";
import {
getElementLocalTime,
resolveColorAtTime,
- resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
-import { getEffect } from "@/lib/effects";
-import { webglEffectRenderer } from "../webgl-effect-renderer";
+import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
+import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
import { clamp } from "@/utils/math";
-function scaleFontSize({
- fontSize,
- canvasHeight,
-}: {
- fontSize: number;
- canvasHeight: number;
-}): number {
- return fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
-}
-
-function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
- return `"${fontFamily.replace(/"/g, '\\"')}"`;
-}
-
const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
@@ -64,9 +46,15 @@ function drawTextDecoration({
}): void {
if (textDecoration === "none" || !textDecoration) return;
- const thickness = Math.max(1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO);
+ const thickness = Math.max(
+ 1,
+ scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO,
+ );
const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
- const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
+ const descent = getMetricDescent({
+ metrics,
+ fallbackFontSize: scaledFontSize,
+ });
let xStart = -lineWidth / 2;
if (textAlign === "left") xStart = 0;
@@ -121,19 +109,6 @@ export class TextNode extends BaseNode<TextNodeParams> {
const x = transform.position.x + this.params.canvasCenter.x;
const y = transform.position.y + this.params.canvasCenter.y;
- const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
- const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal";
- const scaledFontSize = scaleFontSize({
- fontSize: this.params.fontSize,
- canvasHeight: this.params.canvasHeight,
- });
- const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
- const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
- const letterSpacing = this.params.letterSpacing ?? 0;
- const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
- const lines = this.params.content.split("\n");
- const lineHeightPx = scaledFontSize * lineHeight;
- const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const baseline = this.params.textBaseline ?? "middle";
const blendMode = (
this.params.blendMode && this.params.blendMode !== "normal"
@@ -141,73 +116,49 @@ export class TextNode extends BaseNode<TextNodeParams> {
: "source-over"
) as GlobalCompositeOperation;
- renderer.context.save();
- renderer.context.font = fontString;
- renderer.context.textBaseline = baseline;
- if ("letterSpacing" in renderer.context) {
- (renderer.context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
- }
- const lineMetrics = lines.map((line) => renderer.context.measureText(line));
- renderer.context.restore();
+ const {
+ scaledFontSize,
+ fontString,
+ letterSpacing,
+ lineHeightPx,
+ lines,
+ lineMetrics,
+ block,
+ fontSizeRatio,
+ resolvedBackground,
+ } = measureTextElement({
+ element: this.params,
+ canvasHeight: this.params.canvasHeight,
+ localTime,
+ ctx: renderer.context,
+ });
const lineCount = lines.length;
- const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize });
- const textColor = resolveColorAtTime({
+ const textColor = resolveColorAtTime({
baseColor: this.params.color,
animations: this.params.animations,
propertyPath: "color",
localTime,
});
- const bg = this.params.background;
- const resolvedBackground = {
- ...bg,
+ const resolvedBackgroundWithColor = {
+ ...resolvedBackground,
color: resolveColorAtTime({
- baseColor: bg.color,
+ baseColor: this.params.background.color,
animations: this.params.animations,
propertyPath: "background.color",
localTime,
}),
- paddingX: resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
- animations: this.params.animations,
- propertyPath: "background.paddingX",
- localTime,
- }),
- paddingY: resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
- animations: this.params.animations,
- propertyPath: "background.paddingY",
- localTime,
- }),
- offsetX: resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
- animations: this.params.animations,
- propertyPath: "background.offsetX",
- localTime,
- }),
- offsetY: resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
- animations: this.params.animations,
- propertyPath: "background.offsetY",
- localTime,
- }),
- cornerRadius: resolveNumberAtTime({
- baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- animations: this.params.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- }),
};
- const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
+ const drawContent = (
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
+ ) => {
ctx.font = fontString;
ctx.textAlign = this.params.textAlign;
ctx.textBaseline = baseline;
ctx.fillStyle = textColor;
- if ("letterSpacing" in ctx) {
- (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
- }
+ setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
if (
this.params.background.enabled &&
@@ -218,17 +169,29 @@ export class TextNode extends BaseNode<TextNodeParams> {
const backgroundRect = getTextBackgroundRect({
textAlign: this.params.textAlign,
block,
- background: resolvedBackground,
+ background: resolvedBackgroundWithColor,
fontSizeRatio,
});
if (backgroundRect) {
- const p = clamp({ value: resolvedBackground.cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
- const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p;
- ctx.fillStyle = resolvedBackground.color;
- ctx.beginPath();
- ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
- ctx.fill();
- ctx.fillStyle = textColor;
+ const p =
+ clamp({
+ value: resolvedBackgroundWithColor.cornerRadius,
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ }) / 100;
+ const radius =
+ (Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
+ ctx.fillStyle = resolvedBackgroundWithColor.color;
+ ctx.beginPath();
+ ctx.roundRect(
+ backgroundRect.left,
+ backgroundRect.top,
+ backgroundRect.width,
+ backgroundRect.height,
+ radius,
+ );
+ ctx.fill();
+ ctx.fillStyle = textColor;
}
}
@@ -247,15 +210,18 @@ export class TextNode extends BaseNode<TextNodeParams> {
}
};
- const applyTransform = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
+ const applyTransform = (
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
+ ) => {
ctx.translate(x, y);
- ctx.scale(transform.scale, transform.scale);
+ ctx.scale(transform.scaleX, transform.scaleY);
if (transform.rotate) {
ctx.rotate((transform.rotate * Math.PI) / 180);
}
};
- const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? [];
+ const enabledEffects =
+ this.params.effects?.filter((effect) => effect.enabled) ?? [];
if (enabledEffects.length === 0) {
renderer.context.save();
@@ -269,11 +235,16 @@ export class TextNode extends BaseNode<TextNodeParams> {
// Effects path: render text to a same-size offscreen canvas so the blur
// can spread into the surrounding transparent area without hard clipping.
- const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height });
- const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
+ const offscreen = createOffscreenCanvas({
+ width: renderer.width,
+ height: renderer.height,
+ });
+ const offscreenCtx = offscreen.getContext(
+ "2d",
+ ) as OffscreenCanvasRenderingContext2D | null;
if (!offscreenCtx) {
- renderer.context.save();
+ renderer.context.save();
applyTransform(renderer.context);
renderer.context.globalCompositeOperation = blendMode;
renderer.context.globalAlpha = opacity;
@@ -294,15 +265,13 @@ export class TextNode extends BaseNode<TextNodeParams> {
animations: this.params.animations,
localTime,
});
- const definition = getEffect({ effectType: effect.type });
- const passes = definition.renderer.passes.map((pass) => ({
- fragmentShader: pass.fragmentShader,
- uniforms: pass.uniforms({
- effectParams: resolvedParams,
- width: renderer.width,
- height: renderer.height,
- }),
- }));
+ const definition = effectsRegistry.get(effect.type);
+ const passes = resolveEffectPasses({
+ definition,
+ effectParams: resolvedParams,
+ width: renderer.width,
+ height: renderer.height,
+ });
currentSource = webglEffectRenderer.applyEffect({
source: currentSource,
width: renderer.width,
diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts
index 503c9c03..af39d26f 100644
--- a/apps/web/src/services/renderer/nodes/video-node.ts
+++ b/apps/web/src/services/renderer/nodes/video-node.ts
@@ -17,6 +17,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
}
const videoTime = this.getSourceLocalTime({ time });
+
const frame = await videoCache.getFrameAt({
mediaId: this.params.mediaId,
file: this.params.file,
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 6c50aa12..e0f87c52 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -1,10 +1,11 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { createOffscreenCanvas } from "../canvas-utils";
import { BaseNode } from "./base-node";
-import type { Effect } from "@/types/effects";
-import type { BlendMode } from "@/types/rendering";
-import type { Transform } from "@/types/timeline";
-import type { ElementAnimations } from "@/types/animation";
+import type { Effect } from "@/lib/effects/types";
+import type { Mask } from "@/lib/masks/types";
+import type { BlendMode, Transform } from "@/lib/rendering";
+import type { ElementAnimations } from "@/lib/animation/types";
+import type { RetimeConfig } from "@/lib/timeline";
import {
getElementLocalTime,
resolveOpacityAtTime,
@@ -12,26 +13,38 @@ import {
} from "@/lib/animation";
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
-import { getEffect } from "@/lib/effects";
-import { webglEffectRenderer } from "../webgl-effect-renderer";
+import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
+import { masksRegistry } from "@/lib/masks";
+import { getSourceTimeAtClipTime } from "@/lib/retime";
+import { webglEffectRenderer } from "../webgl/webgl-effect-renderer";
+import { applyMaskFeather } from "../mask-feather";
export interface VisualNodeParams {
duration: number;
timeOffset: number;
trimStart: number;
trimEnd: number;
+ retime?: RetimeConfig;
transform: Transform;
animations?: ElementAnimations;
opacity: number;
blendMode?: BlendMode;
effects?: Effect[];
+ masks?: Mask[];
}
export abstract class VisualNode<
Params extends VisualNodeParams = VisualNodeParams,
> extends BaseNode<Params> {
protected getSourceLocalTime({ time }: { time: number }): number {
- return time - this.params.timeOffset + this.params.trimStart;
+ const clipTime = time - this.params.timeOffset;
+ return (
+ this.params.trimStart +
+ getSourceTimeAtClipTime({
+ clipTime,
+ retime: this.params.retime,
+ })
+ );
}
protected getAnimationLocalTime({ time }: { time: number }): number {
@@ -43,10 +56,10 @@ export abstract class VisualNode<
}
protected isInRange({ time }: { time: number }): boolean {
- const localTime = this.getSourceLocalTime({ time });
+ const localTime = time - this.params.timeOffset;
return (
- localTime >= this.params.trimStart - TIME_EPSILON_SECONDS &&
- localTime < this.params.trimStart + this.params.duration
+ localTime >= -TIME_EPSILON_SECONDS &&
+ localTime < this.params.duration
);
}
@@ -65,7 +78,9 @@ export abstract class VisualNode<
}): void {
renderer.context.save();
- const animationLocalTime = this.getAnimationLocalTime({ time: timelineTime });
+ const animationLocalTime = this.getAnimationLocalTime({
+ time: timelineTime,
+ });
const transform = resolveTransformAtTime({
baseTransform: this.params.transform,
animations: this.params.animations,
@@ -80,10 +95,12 @@ export abstract class VisualNode<
renderer.width / sourceWidth,
renderer.height / sourceHeight,
);
- const scaledWidth = sourceWidth * containScale * transform.scale;
- const scaledHeight = sourceHeight * containScale * transform.scale;
- const x = renderer.width / 2 + transform.position.x - scaledWidth / 2;
- const y = renderer.height / 2 + transform.position.y - scaledHeight / 2;
+ const scaledWidth = sourceWidth * containScale * transform.scaleX;
+ const scaledHeight = sourceHeight * containScale * transform.scaleY;
+ const absWidth = Math.abs(scaledWidth);
+ const absHeight = Math.abs(scaledHeight);
+ const x = renderer.width / 2 + transform.position.x - absWidth / 2;
+ const y = renderer.height / 2 + transform.position.y - absHeight / 2;
renderer.context.globalCompositeOperation = (
this.params.blendMode && this.params.blendMode !== "normal"
@@ -92,71 +109,187 @@ export abstract class VisualNode<
) as GlobalCompositeOperation;
renderer.context.globalAlpha = opacity;
- if (transform.rotate !== 0) {
- const centerX = x + scaledWidth / 2;
- const centerY = y + scaledHeight / 2;
+ const flipX = scaledWidth < 0 ? -1 : 1;
+ const flipY = scaledHeight < 0 ? -1 : 1;
+ const needsTransform = transform.rotate !== 0 || flipX !== 1 || flipY !== 1;
+
+ if (needsTransform) {
+ const centerX = x + absWidth / 2;
+ const centerY = y + absHeight / 2;
renderer.context.translate(centerX, centerY);
renderer.context.rotate((transform.rotate * Math.PI) / 180);
+ renderer.context.scale(flipX, flipY);
renderer.context.translate(-centerX, -centerY);
}
const enabledEffects =
this.params.effects?.filter((effect) => effect.enabled) ?? [];
+ const activeMasks = this.params.masks ?? [];
+
+ if (activeMasks.length === 0 && enabledEffects.length === 0) {
+ renderer.context.drawImage(source, x, y, absWidth, absHeight);
+ renderer.context.restore();
+ return;
+ }
+
+ const currentResult =
+ enabledEffects.length > 0
+ ? this.applyEffects({
+ source,
+ effects: enabledEffects,
+ width: absWidth,
+ height: absHeight,
+ animationLocalTime,
+ })
+ : source;
- if (enabledEffects.length === 0) {
- renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ if (activeMasks.length === 0) {
+ renderer.context.drawImage(currentResult, x, y, absWidth, absHeight);
renderer.context.restore();
return;
}
const elementCanvas = createOffscreenCanvas({
- width: Math.round(scaledWidth),
- height: Math.round(scaledHeight),
+ width: Math.round(absWidth),
+ height: Math.round(absHeight),
});
const elementCtx = elementCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!elementCtx) {
- renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
+ renderer.context.drawImage(currentResult, x, y, absWidth, absHeight);
renderer.context.restore();
return;
}
- elementCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight);
+ elementCtx.drawImage(currentResult, 0, 0, absWidth, absHeight);
- let currentResult: CanvasImageSource = elementCanvas;
+ for (const mask of activeMasks) {
+ this.applyMask({
+ mask,
+ elementCtx,
+ scaledWidth: absWidth,
+ scaledHeight: absHeight,
+ });
+ }
+
+ renderer.context.drawImage(elementCanvas, x, y, absWidth, absHeight);
+ renderer.context.restore();
+ }
- for (const effect of enabledEffects) {
+ private applyEffects({
+ source,
+ effects,
+ width,
+ height,
+ animationLocalTime,
+ }: {
+ source: CanvasImageSource;
+ effects: Effect[];
+ width: number;
+ height: number;
+ animationLocalTime: number;
+ }): CanvasImageSource {
+ let current: CanvasImageSource = source;
+ for (const effect of effects) {
const resolvedParams = resolveEffectParamsAtTime({
effect,
animations: this.params.animations,
localTime: animationLocalTime,
});
- const definition = getEffect({ effectType: effect.type });
- const passes = definition.renderer.passes.map((pass) => ({
- fragmentShader: pass.fragmentShader,
- uniforms: pass.uniforms({
- effectParams: resolvedParams,
- width: scaledWidth,
- height: scaledHeight,
- }),
- }));
- currentResult = webglEffectRenderer.applyEffect({
- source: currentResult,
+ const definition = effectsRegistry.get(effect.type);
+ const passes = resolveEffectPasses({
+ definition,
+ effectParams: resolvedParams,
+ width,
+ height,
+ });
+ current = webglEffectRenderer.applyEffect({
+ source: current,
+ width: Math.round(width),
+ height: Math.round(height),
+ passes,
+ });
+ }
+ return current;
+ }
+
+ private applyMask({
+ mask,
+ elementCtx,
+ scaledWidth,
+ scaledHeight,
+ }: {
+ mask: Mask;
+ elementCtx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ scaledWidth: number;
+ scaledHeight: number;
+ }): void {
+ const definition = masksRegistry.get(mask.type);
+ const { feather, inverted } = mask.params;
+
+ const maskCanvas = createOffscreenCanvas({
+ width: Math.round(scaledWidth),
+ height: Math.round(scaledHeight),
+ });
+ const maskCtx = maskCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (!maskCtx) return;
+
+ maskCtx.clearRect(0, 0, scaledWidth, scaledHeight);
+
+ let maskResult: CanvasImageSource = maskCanvas;
+ let path: Path2D | null = null;
+
+ if (feather > 0 && definition.renderer.renderMask) {
+ // Bypasses JFA — avoids the two-sided distance artifact where strips
+ // near the canvas edge appear semi-transparent.
+ definition.renderer.renderMask({
+ resolvedParams: mask.params,
+ ctx: maskCtx,
width: Math.round(scaledWidth),
height: Math.round(scaledHeight),
- passes,
+ feather,
+ });
+ } else {
+ path = definition.renderer.buildPath({
+ resolvedParams: mask.params,
+ width: scaledWidth,
+ height: scaledHeight,
});
+ maskCtx.fillStyle = "white";
+ maskCtx.fill(path);
+
+ if (feather > 0) {
+ maskResult = applyMaskFeather({
+ maskCanvas,
+ width: Math.round(scaledWidth),
+ height: Math.round(scaledHeight),
+ feather,
+ });
+ }
}
- renderer.context.drawImage(
- currentResult,
- x,
- y,
- scaledWidth,
- scaledHeight,
- );
- renderer.context.restore();
+ elementCtx.globalCompositeOperation = inverted
+ ? "destination-out"
+ : "destination-in";
+ elementCtx.drawImage(maskResult, 0, 0, scaledWidth, scaledHeight);
+ elementCtx.globalCompositeOperation = "source-over";
+
+ const strokePath =
+ definition.renderer.buildStrokePath?.({
+ resolvedParams: mask.params,
+ width: scaledWidth,
+ height: scaledHeight,
+ }) ?? path;
+
+ if (mask.params.strokeWidth > 0 && strokePath) {
+ elementCtx.strokeStyle = mask.params.strokeColor;
+ elementCtx.lineWidth = mask.params.strokeWidth;
+ elementCtx.stroke(strokePath);
+ }
}
}
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index 695e077f..7d5f9fe9 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -1,26 +1,22 @@
-import type { TimelineTrack } from "@/types/timeline";
-import type { MediaAsset } from "@/types/assets";
+import type { TimelineTrack } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { RootNode } from "./nodes/root-node";
import { VideoNode } from "./nodes/video-node";
import { ImageNode } from "./nodes/image-node";
import { TextNode } from "./nodes/text-node";
import { StickerNode } from "./nodes/sticker-node";
+import { GraphicNode } from "./nodes/graphic-node";
import { ColorNode } from "./nodes/color-node";
-import { CompositeEffectNode } from "./nodes/composite-effect-node";
+import { BlurBackgroundNode } from "./nodes/blur-background-node";
import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { BaseNode } from "./nodes/base-node";
-import type { TBackground, TCanvasSize } from "@/types/project";
+import type { TBackground, TCanvasSize } from "@/lib/project/types";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { isMainTrack } from "@/lib/timeline";
const PREVIEW_MAX_IMAGE_SIZE = 2048;
-const BLUR_BACKGROUND_ZOOM_SCALE = 1.4;
-function getVisibleSortedElements({
- track,
-}: {
- track: TimelineTrack;
-}) {
+function getVisibleSortedElements({ track }: { track: TimelineTrack }) {
return track.elements
.filter((element) => !("hidden" in element && element.hidden))
.slice()
@@ -65,7 +61,7 @@ function buildTrackNodes({
continue;
}
- if (mediaAsset.type === "video") {
+ if (element.type === "video" && mediaAsset.type === "video") {
nodes.push(
new VideoNode({
mediaId: mediaAsset.id,
@@ -75,15 +71,17 @@ function buildTrackNodes({
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
+ retime: element.retime,
transform: element.transform,
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
effects: element.effects,
+ masks: element.masks,
}),
);
}
- if (mediaAsset.type === "image") {
+ if (element.type === "image" && mediaAsset.type === "image") {
nodes.push(
new ImageNode({
url: mediaAsset.url,
@@ -96,6 +94,7 @@ function buildTrackNodes({
opacity: element.opacity,
blendMode: element.blendMode,
effects: element.effects,
+ masks: element.masks,
...(isPreview && {
maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
}),
@@ -120,6 +119,8 @@ function buildTrackNodes({
nodes.push(
new StickerNode({
stickerId: element.stickerId,
+ intrinsicWidth: element.intrinsicWidth,
+ intrinsicHeight: element.intrinsicHeight,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
@@ -132,12 +133,80 @@ function buildTrackNodes({
}),
);
}
+
+ if (element.type === "graphic") {
+ nodes.push(
+ new GraphicNode({
+ definitionId: element.definitionId,
+ params: element.params,
+ duration: element.duration,
+ timeOffset: element.startTime,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ transform: element.transform,
+ animations: element.animations,
+ opacity: element.opacity,
+ blendMode: element.blendMode,
+ effects: element.effects,
+ masks: element.masks,
+ }),
+ );
+ }
}
}
return nodes;
}
+function buildBlurBackgroundNodes({
+ track,
+ mediaMap,
+ blurIntensity,
+}: {
+ track: TimelineTrack | undefined;
+ mediaMap: Map<string, MediaAsset>;
+ blurIntensity: number;
+}): BaseNode[] {
+ if (!track) {
+ return [];
+ }
+
+ const nodes: BaseNode[] = [];
+ const elements = getVisibleSortedElements({ track });
+
+ for (const element of elements) {
+ if (element.type !== "video" && element.type !== "image") {
+ continue;
+ }
+
+ const mediaAsset = mediaMap.get(element.mediaId);
+ if (
+ !mediaAsset?.file ||
+ !mediaAsset?.url ||
+ (mediaAsset.type !== "video" && mediaAsset.type !== "image")
+ ) {
+ continue;
+ }
+
+ nodes.push(
+ new BlurBackgroundNode({
+ mediaId: mediaAsset.id,
+ url: mediaAsset.url,
+ file: mediaAsset.file,
+ mediaType: mediaAsset.type,
+ duration: element.duration,
+ timeOffset: element.startTime,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ retime: element.type === "video" ? element.retime : undefined,
+ blurIntensity,
+ }),
+ );
+ }
+
+ return nodes;
+}
+
export type BuildSceneParams = {
canvasSize: TCanvasSize;
tracks: TimelineTrack[];
@@ -168,6 +237,9 @@ export function buildScene({
];
const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
+ const mainTrack = orderedTracksBottomToTop.find((track) =>
+ isMainTrack(track),
+ );
const allNodes = buildTrackNodes({
tracks: orderedTracksBottomToTop,
@@ -177,20 +249,18 @@ export function buildScene({
});
if (background.type === "blur") {
- rootNode.add(
- new CompositeEffectNode({
- contentNodes: allNodes.filter(
- (node) => !(node instanceof EffectLayerNode),
- ),
- effectType: "blur",
- effectParams: {
- intensity:
- background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
- },
- scale: BLUR_BACKGROUND_ZOOM_SCALE,
- }),
- );
- } else if (background.type === "color" && background.color !== "transparent") {
+ const blurNodes = buildBlurBackgroundNodes({
+ track: mainTrack,
+ mediaMap,
+ blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
+ });
+ for (const node of blurNodes) {
+ rootNode.add(node);
+ }
+ } else if (
+ background.type === "color" &&
+ background.color !== "transparent"
+ ) {
rootNode.add(new ColorNode({ color: background.color }));
}
diff --git a/apps/web/src/services/renderer/scene-exporter.ts b/apps/web/src/services/renderer/scene-exporter.ts
index c5bad94f..6ab8c6f3 100644
--- a/apps/web/src/services/renderer/scene-exporter.ts
+++ b/apps/web/src/services/renderer/scene-exporter.ts
@@ -13,7 +13,7 @@ import {
QUALITY_VERY_HIGH,
} from "mediabunny";
import type { RootNode } from "./nodes/root-node";
-import type { ExportFormat, ExportQuality } from "@/types/export";
+import type { ExportFormat, ExportQuality } from "@/lib/export";
import { CanvasRenderer } from "./canvas-renderer";
type ExportParams = {
diff --git a/apps/web/src/services/renderer/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl-effect-renderer.ts
deleted file mode 100644
index ad5cd40e..00000000
--- a/apps/web/src/services/renderer/webgl-effect-renderer.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { createOffscreenCanvas } from "./canvas-utils";
-import { applyMultiPassEffect } from "./webgl-utils";
-import type { EffectPassData } from "./webgl-utils";
-
-export interface ApplyEffectParams {
- source: CanvasImageSource;
- width: number;
- height: number;
- passes: EffectPassData[];
-}
-
-let gl: WebGLRenderingContext | null = null;
-let canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
-const programCache = new Map<string, WebGLProgram>();
-
-function getOrCreateCanvas({
- width,
- height,
-}: {
- width: number;
- height: number;
-}): OffscreenCanvas | HTMLCanvasElement {
- if (!canvas) {
- canvas = createOffscreenCanvas({ width, height });
- gl = canvas.getContext("webgl", {
- premultipliedAlpha: false,
- }) as WebGLRenderingContext | null;
- if (!gl) {
- throw new Error("WebGL not supported");
- }
- }
- if (canvas.width !== width || canvas.height !== height) {
- canvas.width = width;
- canvas.height = height;
- }
- return canvas;
-}
-
-function applyEffect({
- source,
- width,
- height,
- passes,
-}: ApplyEffectParams): OffscreenCanvas | HTMLCanvasElement {
- const targetCanvas = getOrCreateCanvas({ width, height });
- const context = gl;
- if (!context) {
- throw new Error("WebGL context not initialized");
- }
-
- applyMultiPassEffect({
- context,
- source,
- width,
- height,
- passes,
- programCache,
- });
-
- const outputCanvas = createOffscreenCanvas({ width, height });
- const outputCtx = outputCanvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (outputCtx) {
- outputCtx.drawImage(targetCanvas, 0, 0, width, height);
- }
- return outputCanvas;
-}
-
-export const webglEffectRenderer = {
- applyEffect,
-};
diff --git a/apps/web/src/services/renderer/webgl/jfa.ts b/apps/web/src/services/renderer/webgl/jfa.ts
new file mode 100644
index 00000000..b69a9d06
--- /dev/null
+++ b/apps/web/src/services/renderer/webgl/jfa.ts
@@ -0,0 +1,194 @@
+import jfaInitShader from "@/lib/shaders/jfa-init.frag.glsl";
+import jfaStepShader from "@/lib/shaders/jfa-step.frag.glsl";
+import {
+ compileProgram,
+ createFramebufferTexture,
+ setUniforms,
+ drawFullscreenQuad,
+} from "./webgl-utils";
+
+interface FBPair {
+ texture: WebGLTexture;
+ framebuffer: WebGLFramebuffer;
+}
+
+function runPass({
+ context,
+ program,
+ inputTexture,
+ target,
+ width,
+ height,
+ uniforms,
+ extraBindings,
+}: {
+ context: WebGLRenderingContext;
+ program: WebGLProgram;
+ inputTexture: WebGLTexture;
+ target: WebGLFramebuffer | null;
+ width: number;
+ height: number;
+ uniforms: Record<string, number | number[]>;
+ extraBindings?: Array<{ unit: number; texture: WebGLTexture; name: string }>;
+}): void {
+ context.bindFramebuffer(context.FRAMEBUFFER, target);
+ // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook
+ context.useProgram(program);
+
+ context.activeTexture(context.TEXTURE0);
+ context.bindTexture(context.TEXTURE_2D, inputTexture);
+ const uTexLoc = context.getUniformLocation(program, "u_texture");
+ if (uTexLoc) context.uniform1i(uTexLoc, 0);
+
+ if (extraBindings) {
+ for (const binding of extraBindings) {
+ context.activeTexture(context.TEXTURE0 + binding.unit);
+ context.bindTexture(context.TEXTURE_2D, binding.texture);
+ const loc = context.getUniformLocation(program, binding.name);
+ if (loc) context.uniform1i(loc, binding.unit);
+ }
+ }
+
+ setUniforms({
+ context,
+ program,
+ uniforms: { ...uniforms, u_resolution: [width, height] },
+ });
+ drawFullscreenQuad({ context, program, width, height });
+}
+
+function runJFA({
+ context,
+ programCache,
+ sourceTexture,
+ width,
+ height,
+ isInverted,
+}: {
+ context: WebGLRenderingContext;
+ programCache: Map<string, WebGLProgram>;
+ sourceTexture: WebGLTexture;
+ width: number;
+ height: number;
+ isInverted: boolean;
+}): {
+ resultTexture: WebGLTexture;
+ resultFB: WebGLFramebuffer;
+ tempFBs: FBPair[];
+} {
+ const numSteps = Math.ceil(Math.log2(Math.max(width, height)));
+
+ const fbA = createFramebufferTexture({ context, width, height });
+ const fbB = createFramebufferTexture({ context, width, height });
+
+ const initProgram = compileProgram({
+ context,
+ fragmentShaderSource: jfaInitShader,
+ programCache,
+ });
+ runPass({
+ context,
+ program: initProgram,
+ inputTexture: sourceTexture,
+ target: fbA.framebuffer,
+ width,
+ height,
+ uniforms: { u_invert: isInverted ? 1.0 : 0.0 },
+ });
+
+ const stepProgram = compileProgram({
+ context,
+ fragmentShaderSource: jfaStepShader,
+ programCache,
+ });
+
+ let readFB = fbA;
+ let writeFB = fbB;
+
+ for (let i = numSteps - 1; i >= 0; i--) {
+ const stepSize = 2 ** i;
+ runPass({
+ context,
+ program: stepProgram,
+ inputTexture: readFB.texture,
+ target: writeFB.framebuffer,
+ width,
+ height,
+ uniforms: { u_step_size: stepSize },
+ });
+ const tmp = readFB;
+ readFB = writeFB;
+ writeFB = tmp;
+ }
+
+ return {
+ resultTexture: readFB.texture,
+ resultFB: readFB.framebuffer,
+ tempFBs: [writeFB],
+ };
+}
+
+function cleanupJFAResult({
+ context,
+ result,
+}: {
+ context: WebGLRenderingContext;
+ result: {
+ resultTexture: WebGLTexture;
+ resultFB: WebGLFramebuffer;
+ tempFBs: FBPair[];
+ };
+}): void {
+ context.deleteTexture(result.resultTexture);
+ context.deleteFramebuffer(result.resultFB);
+ for (const fb of result.tempFBs) {
+ context.deleteTexture(fb.texture);
+ context.deleteFramebuffer(fb.framebuffer);
+ }
+}
+
+export { runPass };
+
+export function computeSignedDistanceField({
+ context,
+ programCache,
+ sourceTexture,
+ width,
+ height,
+}: {
+ context: WebGLRenderingContext;
+ programCache: Map<string, WebGLProgram>;
+ sourceTexture: WebGLTexture;
+ width: number;
+ height: number;
+}): {
+ insideTexture: WebGLTexture;
+ outsideTexture: WebGLTexture;
+ cleanup: () => void;
+} {
+ const inside = runJFA({
+ context,
+ programCache,
+ sourceTexture,
+ width,
+ height,
+ isInverted: false,
+ });
+ const outside = runJFA({
+ context,
+ programCache,
+ sourceTexture,
+ width,
+ height,
+ isInverted: true,
+ });
+
+ return {
+ insideTexture: inside.resultTexture,
+ outsideTexture: outside.resultTexture,
+ cleanup: () => {
+ cleanupJFAResult({ context, result: inside });
+ cleanupJFAResult({ context, result: outside });
+ },
+ };
+}
diff --git a/apps/web/src/services/renderer/webgl/webgl-context.ts b/apps/web/src/services/renderer/webgl/webgl-context.ts
new file mode 100644
index 00000000..aeefc503
--- /dev/null
+++ b/apps/web/src/services/renderer/webgl/webgl-context.ts
@@ -0,0 +1,49 @@
+import { createOffscreenCanvas } from "../canvas-utils";
+
+let gl: WebGLRenderingContext | null = null;
+let webglCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+const programCache = new Map<string, WebGLProgram>();
+
+export function getWebGLContext({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): {
+ context: WebGLRenderingContext;
+ programCache: Map<string, WebGLProgram>;
+} {
+ if (!webglCanvas) {
+ webglCanvas = createOffscreenCanvas({ width, height });
+ gl = webglCanvas.getContext("webgl", {
+ premultipliedAlpha: false,
+ }) as WebGLRenderingContext | null;
+ if (!gl) throw new Error("WebGL not supported");
+ }
+ if (webglCanvas.width !== width || webglCanvas.height !== height) {
+ webglCanvas.width = width;
+ webglCanvas.height = height;
+ }
+ if (!gl) throw new Error("WebGL context lost");
+ return { context: gl, programCache };
+}
+
+export function readResult({
+ width,
+ height,
+}: {
+ width: number;
+ height: number;
+}): OffscreenCanvas | HTMLCanvasElement {
+ if (!webglCanvas) throw new Error("WebGL canvas not initialized");
+ const outputCanvas = createOffscreenCanvas({ width, height });
+ const outputCtx = outputCanvas.getContext("2d") as
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null;
+ if (outputCtx) {
+ outputCtx.drawImage(webglCanvas, 0, 0, width, height);
+ }
+ return outputCanvas;
+}
diff --git a/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts b/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts
new file mode 100644
index 00000000..89dcb2ae
--- /dev/null
+++ b/apps/web/src/services/renderer/webgl/webgl-effect-renderer.ts
@@ -0,0 +1,35 @@
+import { getWebGLContext, readResult } from "./webgl-context";
+import { applyMultiPassEffect } from "./webgl-utils";
+import type { EffectPassData } from "./webgl-utils";
+
+export interface ApplyEffectParams {
+ source: CanvasImageSource;
+ width: number;
+ height: number;
+ passes: EffectPassData[];
+}
+
+function applyEffect({
+ source,
+ width,
+ height,
+ passes,
+}: ApplyEffectParams): CanvasImageSource {
+ if (passes.length === 0) {
+ return source;
+ }
+ const { context, programCache } = getWebGLContext({ width, height });
+ applyMultiPassEffect({
+ context,
+ source,
+ width,
+ height,
+ passes,
+ programCache,
+ });
+ return readResult({ width, height });
+}
+
+export const webglEffectRenderer = {
+ applyEffect,
+};
diff --git a/apps/web/src/services/renderer/webgl-utils.ts b/apps/web/src/services/renderer/webgl/webgl-utils.ts
index 834c652e..834c652e 100644
--- a/apps/web/src/services/renderer/webgl-utils.ts
+++ b/apps/web/src/services/renderer/webgl/webgl-utils.ts
diff --git a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts
new file mode 100644
index 00000000..f2a4908f
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV15ToV16 } from "../transformers/v15-to-v16";
+
+describe("V15 to V16 Migration", () => {
+ test("renames sticker tracks to graphic tracks", () => {
+ const result = transformProjectV15ToV16({
+ project: {
+ id: "project-v15",
+ version: 15,
+ metadata: {
+ id: "project-v15",
+ name: "Project",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ },
+ settings: {
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ currentSceneId: "scene-main",
+ scenes: [
+ {
+ id: "scene-main",
+ name: "Main scene",
+ isMain: true,
+ tracks: [
+ {
+ id: "track-graphic",
+ type: "sticker",
+ name: "Sticker Track",
+ hidden: false,
+ elements: [
+ {
+ id: "sticker-1",
+ type: "sticker",
+ name: "Logo",
+ stickerId: "icons:mdi:home",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ },
+ opacity: 1,
+ },
+ ],
+ },
+ ],
+ bookmarks: [],
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(16);
+ expect(
+ (result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0]
+ .tracks[0].type,
+ ).toBe("graphic");
+ });
+
+ test("skips projects already on v16", () => {
+ const result = transformProjectV15ToV16({
+ project: {
+ id: "project-v16",
+ version: 16,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v16");
+ });
+
+ test("skips projects with no id", () => {
+ const result = transformProjectV15ToV16({
+ project: {
+ version: 15,
+ scenes: [],
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts
new file mode 100644
index 00000000..e246943e
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV16ToV17 } from "../transformers/v16-to-v17";
+
+describe("V16 to V17 Migration", () => {
+ test("adds center stroke alignment to masks that do not have it", () => {
+ const result = transformProjectV16ToV17({
+ project: {
+ id: "project-v16",
+ version: 16,
+ metadata: {
+ id: "project-v16",
+ name: "Project",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ },
+ settings: {
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ currentSceneId: "scene-main",
+ scenes: [
+ {
+ id: "scene-main",
+ name: "Main scene",
+ isMain: true,
+ tracks: [
+ {
+ id: "track-video",
+ type: "video",
+ name: "Video Track",
+ hidden: false,
+ elements: [
+ {
+ id: "video-1",
+ type: "video",
+ name: "Clip",
+ mediaId: "media-1",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ },
+ opacity: 1,
+ masks: [
+ {
+ id: "mask-1",
+ type: "rectangle",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 8,
+ centerX: 0,
+ centerY: 0,
+ width: 0.6,
+ height: 0.6,
+ rotation: 0,
+ scale: 1,
+ },
+ },
+ {
+ id: "mask-2",
+ type: "ellipse",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 8,
+ strokeAlign: "outside",
+ centerX: 0,
+ centerY: 0,
+ width: 0.6,
+ height: 0.6,
+ rotation: 0,
+ scale: 1,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ bookmarks: [],
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(17);
+
+ const migratedMasks = (
+ ((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record<string, unknown> }> }> }> }>)[0]
+ .tracks[0].elements[0].masks)
+ );
+
+ expect(migratedMasks[0].params.strokeAlign).toBe("center");
+ expect(migratedMasks[1].params.strokeAlign).toBe("outside");
+ });
+
+ test("skips projects already on v17", () => {
+ const result = transformProjectV16ToV17({
+ project: {
+ id: "project-v17",
+ version: 17,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v17");
+ });
+
+ test("skips projects with no id", () => {
+ const result = transformProjectV16ToV17({
+ project: {
+ version: 16,
+ scenes: [],
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts
new file mode 100644
index 00000000..7524a8d4
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV18ToV19 } from "../transformers/v18-to-v19";
+
+describe("V18 to V19 Migration", () => {
+ test("adds canvas size mode and empty remembered custom size defaults", () => {
+ const result = transformProjectV18ToV19({
+ project: {
+ id: "project-v18-defaults",
+ version: 18,
+ metadata: {
+ id: "project-v18-defaults",
+ name: "Project",
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+ },
+ settings: {
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ originalCanvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ currentSceneId: "scene-main",
+ scenes: [],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(19);
+ expect(
+ (result.project.settings as Record<string, unknown>).canvasSizeMode,
+ ).toBe("preset");
+ expect(
+ (result.project.settings as Record<string, unknown>).lastCustomCanvasSize,
+ ).toBeNull();
+ expect(
+ (result.project.settings as Record<string, unknown>).originalCanvasSize,
+ ).toEqual({ width: 1920, height: 1080 });
+ });
+
+ test("skips projects already on v19", () => {
+ const result = transformProjectV18ToV19({
+ project: {
+ id: "project-v19",
+ version: 19,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v19");
+ });
+
+ test("skips projects with no id", () => {
+ const result = transformProjectV18ToV19({
+ project: {
+ version: 18,
+ scenes: [],
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index bffacb66..1a855287 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -8,10 +8,20 @@ import { V5toV6Migration } from "./v5-to-v6";
import { V6toV7Migration } from "./v6-to-v7";
import { V7toV8Migration } from "./v7-to-v8";
import { V8toV9Migration } from "./v8-to-v9";
+import { V9toV10Migration } from "./v9-to-v10";
+import { V10toV11Migration } from "./v10-to-v11";
+import { V11toV12Migration } from "./v11-to-v12";
+import { V12toV13Migration } from "./v12-to-v13";
+import { V13toV14Migration } from "./v13-to-v14";
+import { V14toV15Migration } from "./v14-to-v15";
+import { V15toV16Migration } from "./v15-to-v16";
+import { V16toV17Migration } from "./v16-to-v17";
+import { V17toV18Migration } from "./v17-to-v18";
+import { V18toV19Migration } from "./v18-to-v19";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 9;
+export const CURRENT_PROJECT_VERSION = 19;
export const migrations = [
new V0toV1Migration(),
@@ -23,4 +33,14 @@ export const migrations = [
new V6toV7Migration(),
new V7toV8Migration(),
new V8toV9Migration(),
+ new V9toV10Migration(),
+ new V10toV11Migration(),
+ new V11toV12Migration(),
+ new V12toV13Migration(),
+ new V13toV14Migration(),
+ new V14toV15Migration(),
+ new V15toV16Migration(),
+ new V16toV17Migration(),
+ new V17toV18Migration(),
+ new V18toV19Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts
index 8878d6ba..c1ae894f 100644
--- a/apps/web/src/services/storage/migrations/transformers/index.ts
+++ b/apps/web/src/services/storage/migrations/transformers/index.ts
@@ -3,4 +3,5 @@ export { transformProjectV1ToV2 } from "./v1-to-v2";
export { transformProjectV2ToV3 } from "./v2-to-v3";
export { transformProjectV3ToV4 } from "./v3-to-v4";
export { transformProjectV4ToV5 } from "./v4-to-v5";
+export { transformProjectV17ToV18 } from "./v17-to-v18";
export type { MigrationResult, ProjectRecord } from "./types";
diff --git a/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts b/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts
index a26feb3b..b1fcf00b 100644
--- a/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts
@@ -1,8 +1,17 @@
import { generateUUID } from "@/utils/id";
-import type { SerializedScene } from "@/services/storage/types";
import type { MigrationResult, ProjectRecord } from "./types";
import { isRecord } from "./utils";
+interface V1Scene {
+ id: string;
+ name: string;
+ isMain: boolean;
+ tracks: unknown[];
+ bookmarks: unknown[];
+ createdAt: string;
+ updatedAt: string;
+}
+
export interface TransformV0ToV1Options {
now?: Date;
}
@@ -25,7 +34,7 @@ export function transformProjectV0ToV1({
const sceneCreatedAt = now.toISOString();
const sceneUpdatedAt = now.toISOString();
- const mainScene: SerializedScene = {
+ const mainScene: V1Scene = {
id: sceneId,
name: "Main scene",
isMain: true,
diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
index 297d1c58..52701cd5 100644
--- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
@@ -6,14 +6,6 @@ import {
} from "@/constants/project-constants";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import type { MediaAssetData } from "@/services/storage/types";
-import type {
- AudioElement,
- ImageElement,
- TextElement,
- TimelineTrack,
- Transform,
- VideoElement,
-} from "@/types/timeline";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
@@ -50,6 +42,112 @@ interface LegacyMediaTrack {
[key: string]: unknown;
}
+interface V2Transform {
+ scale: number;
+ position: { x: number; y: number };
+ rotate: number;
+}
+
+interface V2VideoElement {
+ id: string;
+ name: string;
+ type: "video";
+ mediaId: string;
+ muted: boolean;
+ hidden: boolean;
+ transform: V2Transform;
+ opacity: number;
+ duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
+}
+
+interface V2ImageElement {
+ id: string;
+ name: string;
+ type: "image";
+ mediaId: string;
+ duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
+ hidden: boolean;
+ transform: V2Transform;
+ opacity: number;
+}
+
+interface V2TextElement {
+ id: string;
+ name: string;
+ type: "text";
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ color: string;
+ background: {
+ enabled: boolean;
+ color: string;
+ cornerRadius: number;
+ paddingX: number;
+ paddingY: number;
+ offsetX: number;
+ offsetY: number;
+ };
+ textAlign: "left" | "center" | "right";
+ fontWeight: "normal" | "bold";
+ fontStyle: "normal" | "italic";
+ textDecoration: "none" | "underline" | "line-through";
+ hidden: boolean;
+ transform: V2Transform;
+ opacity: number;
+ duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
+}
+
+interface V2AudioElement {
+ id: string;
+ name: string;
+ type: "audio";
+ sourceType: "upload";
+ mediaId: string;
+ volume: number;
+ duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
+}
+
+interface V2VideoTrack {
+ id: string;
+ name: string;
+ type: "video";
+ elements: (V2VideoElement | V2ImageElement)[];
+ isMain: boolean;
+ muted: boolean;
+ hidden: boolean;
+}
+
+interface V2TextTrack {
+ id: string;
+ name: string;
+ type: "text";
+ elements: V2TextElement[];
+ hidden: boolean;
+}
+
+interface V2AudioTrack {
+ id: string;
+ name: string;
+ type: "audio";
+ elements: V2AudioElement[];
+ muted: boolean;
+}
+
+type V2TimelineTrack = V2VideoTrack | V2TextTrack | V2AudioTrack;
+
export interface TransformV1ToV2Options {
loadMediaAsset?: ({
mediaId,
@@ -259,13 +357,13 @@ async function transformTracks({
}: {
mediaId: string;
}) => Promise<MediaAssetData | null>;
-}): Promise<TimelineTrack[]> {
+}): Promise<V2TimelineTrack[]> {
if (!Array.isArray(tracks)) {
return [];
}
let isFirstVideoTrackFound = false;
- const transformedTracks: (TimelineTrack | null)[] = [];
+ const transformedTracks: (V2TimelineTrack | null)[] = [];
for (const track of tracks) {
if (!isRecord(track)) {
@@ -299,7 +397,7 @@ async function transformTracks({
}
return transformedTracks.filter(
- (track): track is TimelineTrack => track !== null,
+ (track): track is V2TimelineTrack => track !== null,
);
}
@@ -315,7 +413,7 @@ async function transformMediaTrack({
mediaId: string;
}) => Promise<MediaAssetData | null>;
isMain: boolean;
-}): Promise<TimelineTrack> {
+}): Promise<V2VideoTrack> {
const elements = Array.isArray(track.elements) ? track.elements : [];
const transformedElements = await Promise.all(
@@ -338,7 +436,7 @@ async function transformMediaTrack({
}
}
- const defaultTransform: Transform = {
+ const defaultTransform: V2Transform = {
scale: 1,
position: { x: 0, y: 0 },
rotate: 0,
@@ -347,7 +445,7 @@ async function transformMediaTrack({
const muted = mediaElement.muted === true;
if (mediaType === "image") {
- const imageElement: ImageElement = {
+ const imageElement: V2ImageElement = {
id: getStringValue({ value: element.id, fallback: "" }),
name: getStringValue({ value: element.name, fallback: "" }),
type: "image",
@@ -369,7 +467,7 @@ async function transformMediaTrack({
return imageElement;
}
- const videoElement: VideoElement = {
+ const videoElement: V2VideoElement = {
id: getStringValue({ value: element.id, fallback: "" }),
name: getStringValue({ value: element.name, fallback: "" }),
type: "video",
@@ -388,7 +486,7 @@ async function transformMediaTrack({
);
const validElements = transformedElements.filter(
- (element): element is VideoElement | ImageElement => element !== null,
+ (element): element is V2VideoElement | V2ImageElement => element !== null,
);
return {
@@ -406,11 +504,11 @@ function transformTextTrack({
track,
}: {
track: Record<string, unknown>;
-}): TimelineTrack {
+}): V2TextTrack {
const elements = Array.isArray(track.elements) ? track.elements : [];
const transformedElements = elements
- .map((element): TextElement | null => {
+ .map((element): V2TextElement | null => {
if (!isRecord(element) || element.type !== "text") {
return null;
}
@@ -427,7 +525,7 @@ function transformTextTrack({
fallback: 1,
});
- const transform: Transform = {
+ const transform: V2Transform = {
scale: 1,
position: { x, y },
rotate: rotation,
@@ -487,7 +585,7 @@ function transformTextTrack({
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
};
})
- .filter((element): element is TextElement => element !== null);
+ .filter((element): element is V2TextElement => element !== null);
return {
id: getStringValue({ value: track.id, fallback: "" }),
@@ -502,11 +600,11 @@ function transformAudioTrack({
track,
}: {
track: Record<string, unknown>;
-}): TimelineTrack {
+}): V2AudioTrack {
const elements = Array.isArray(track.elements) ? track.elements : [];
const transformedElements = elements
- .map((element): AudioElement | null => {
+ .map((element): V2AudioElement | null => {
if (!isRecord(element) || element.type !== "audio") {
return null;
}
@@ -530,7 +628,7 @@ function transformAudioTrack({
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
};
})
- .filter((element): element is AudioElement => element !== null);
+ .filter((element): element is V2AudioElement => element !== null);
return {
id: getStringValue({ value: track.id, fallback: "" }),
diff --git a/apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts b/apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts
new file mode 100644
index 00000000..b48f5e88
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts
@@ -0,0 +1,113 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV10ToV11({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (typeof project.version === "number" && project.version >= 11) {
+ return { project, skipped: true, reason: "already v11" };
+ }
+
+ const migratedProject = migrateProjectScale({ project });
+
+ return {
+ project: { ...migratedProject, version: 11 },
+ skipped: false,
+ };
+}
+
+function migrateProjectScale({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) return project;
+
+ const migratedScenes = scenesValue.map((scene) =>
+ migrateSceneScale({ scene }),
+ );
+
+ return { ...project, scenes: migratedScenes };
+}
+
+function migrateSceneScale({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) return scene;
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) return scene;
+
+ const migratedTracks = tracksValue.map((track) =>
+ migrateTrackScale({ track }),
+ );
+
+ return { ...scene, tracks: migratedTracks };
+}
+
+function migrateTrackScale({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) return track;
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) return track;
+
+ const migratedElements = elementsValue.map((element) =>
+ migrateElementScale({ element }),
+ );
+
+ return { ...track, elements: migratedElements };
+}
+
+function migrateElementScale({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) return element;
+
+ const transform = element.transform;
+ if (!isRecord(transform)) return element;
+
+ const scale = transform.scale;
+ if (typeof scale !== "number") return element;
+
+ const migratedTransform = {
+ ...transform,
+ scaleX: scale,
+ scaleY: scale,
+ };
+ delete (migratedTransform as Record<string, unknown>).scale;
+
+ let migratedElement: ProjectRecord = {
+ ...element,
+ transform: migratedTransform,
+ };
+
+ const animations = element.animations;
+ if (isRecord(animations) && isRecord(animations.channels)) {
+ const channels = animations.channels as Record<string, unknown>;
+ const scaleChannel = channels["transform.scale"];
+ if (scaleChannel && isRecord(scaleChannel)) {
+ const keyframes = (scaleChannel as { keyframes?: unknown[] }).keyframes;
+ if (Array.isArray(keyframes)) {
+ const newChannels = { ...channels };
+ delete newChannels["transform.scale"];
+ newChannels["transform.scaleX"] = {
+ ...scaleChannel,
+ keyframes: [...keyframes],
+ };
+ newChannels["transform.scaleY"] = {
+ ...scaleChannel,
+ keyframes: keyframes.map((kf) => (isRecord(kf) ? { ...kf } : kf)),
+ };
+ migratedElement = {
+ ...migratedElement,
+ animations: { ...animations, channels: newChannels },
+ };
+ }
+ }
+ }
+
+ return migratedElement;
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts b/apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts
new file mode 100644
index 00000000..fa1bd371
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts
@@ -0,0 +1,196 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV11ToV12({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (typeof project.version === "number" && project.version >= 12) {
+ return { project, skipped: true, reason: "already v12" };
+ }
+
+ const migratedProject = migrateProjectPosition({ project });
+
+ return {
+ project: { ...migratedProject, version: 12 },
+ skipped: false,
+ };
+}
+
+function migrateProjectPosition({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) return project;
+
+ const migratedScenes = scenesValue.map((scene) =>
+ migrateScenePosition({ scene }),
+ );
+
+ return { ...project, scenes: migratedScenes };
+}
+
+function migrateScenePosition({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) return scene;
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) return scene;
+
+ const migratedTracks = tracksValue.map((track) =>
+ migrateTrackPosition({ track }),
+ );
+
+ return { ...scene, tracks: migratedTracks };
+}
+
+function migrateTrackPosition({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) return track;
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) return track;
+
+ const migratedElements = elementsValue.map((element) =>
+ migrateElementPosition({ element }),
+ );
+
+ return { ...track, elements: migratedElements };
+}
+
+function migrateElementPosition({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) return element;
+
+ const animations = element.animations;
+ if (!isRecord(animations) || !isRecord(animations.channels)) return element;
+
+ const channels = animations.channels as Record<string, unknown>;
+ const xChannel = channels["transform.position.x"];
+ const yChannel = channels["transform.position.y"];
+
+ if (!xChannel && !yChannel) return element;
+
+ const baseTransform = isRecord(element.transform) ? element.transform : {};
+ const basePosition = isRecord(baseTransform.position)
+ ? baseTransform.position
+ : {};
+ const baseX = typeof basePosition.x === "number" ? basePosition.x : 0;
+ const baseY = typeof basePosition.y === "number" ? basePosition.y : 0;
+
+ const xKeyframes = getKeyframes({ channel: xChannel });
+ const yKeyframes = getKeyframes({ channel: yChannel });
+
+ const allTimes = Array.from(
+ new Set([
+ ...xKeyframes.map((kf) => kf.time),
+ ...yKeyframes.map((kf) => kf.time),
+ ]),
+ ).sort((a, b) => a - b);
+
+ const vectorKeyframes = allTimes.map((time) => {
+ const xKf = xKeyframes.find((kf) => Math.abs(kf.time - time) < 0.001);
+ const yKf = yKeyframes.find((kf) => Math.abs(kf.time - time) < 0.001);
+ const x =
+ typeof xKf?.value === "number"
+ ? xKf.value
+ : interpolateScalarAtTime({
+ keyframes: xKeyframes,
+ time,
+ fallback: baseX,
+ });
+ const y =
+ typeof yKf?.value === "number"
+ ? yKf.value
+ : interpolateScalarAtTime({
+ keyframes: yKeyframes,
+ time,
+ fallback: baseY,
+ });
+ const interpolation = xKf?.interpolation ?? yKf?.interpolation ?? "linear";
+ return {
+ id: xKf?.id ?? yKf?.id ?? crypto.randomUUID(),
+ time,
+ value: { x, y },
+ interpolation,
+ };
+ });
+
+ const newChannels = { ...channels };
+ delete newChannels["transform.position.x"];
+ delete newChannels["transform.position.y"];
+ newChannels["transform.position"] = {
+ valueKind: "vector",
+ keyframes: vectorKeyframes,
+ };
+
+ return {
+ ...element,
+ animations: { ...animations, channels: newChannels },
+ };
+}
+
+interface ScalarKeyframe {
+ id: string;
+ time: number;
+ value: number;
+ interpolation: string;
+}
+
+function getKeyframes({ channel }: { channel: unknown }): ScalarKeyframe[] {
+ if (!isRecord(channel)) return [];
+ if (!Array.isArray(channel.keyframes)) return [];
+ return channel.keyframes.flatMap((kf) => {
+ if (!isRecord(kf)) return [];
+ if (
+ typeof kf.id !== "string" ||
+ typeof kf.time !== "number" ||
+ typeof kf.value !== "number"
+ )
+ return [];
+ return [
+ {
+ id: kf.id,
+ time: kf.time,
+ value: kf.value,
+ interpolation:
+ typeof kf.interpolation === "string" ? kf.interpolation : "linear",
+ },
+ ];
+ });
+}
+
+function interpolateScalarAtTime({
+ keyframes,
+ time,
+ fallback,
+}: {
+ keyframes: ScalarKeyframe[];
+ time: number;
+ fallback: number;
+}): number {
+ if (keyframes.length === 0) return fallback;
+
+ const sorted = [...keyframes].sort((a, b) => a.time - b.time);
+ const first = sorted[0];
+ const last = sorted[sorted.length - 1];
+
+ if (!first || !last) return fallback;
+ if (time <= first.time) return first.value;
+ if (time >= last.time) return last.value;
+
+ for (let i = 0; i < sorted.length - 1; i++) {
+ const left = sorted[i];
+ const right = sorted[i + 1];
+ if (time < left.time || time > right.time) continue;
+ if (left.interpolation === "hold") return left.value;
+ const t = (time - left.time) / (right.time - left.time);
+ return left.value + (right.value - left.value) * t;
+ }
+
+ return last.value;
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts b/apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts
new file mode 100644
index 00000000..59b29732
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts
@@ -0,0 +1,102 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV12ToV13({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (typeof project.version === "number" && project.version >= 13) {
+ return { project, skipped: true, reason: "already v13" };
+ }
+
+ const migratedProject = migrateProjectMasks({ project });
+
+ return {
+ project: { ...migratedProject, version: 13 },
+ skipped: false,
+ };
+}
+
+function migrateProjectMasks({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) return project;
+
+ const migratedScenes = scenesValue.map((scene) =>
+ migrateSceneMasks({ scene }),
+ );
+
+ return { ...project, scenes: migratedScenes };
+}
+
+function migrateSceneMasks({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) return scene;
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) return scene;
+
+ const migratedTracks = tracksValue.map((track) =>
+ migrateTrackMasks({ track }),
+ );
+
+ return { ...scene, tracks: migratedTracks };
+}
+
+function migrateTrackMasks({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) return track;
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) return track;
+
+ const migratedElements = elementsValue.map((element) =>
+ migrateElementMasks({ element }),
+ );
+
+ return { ...track, elements: migratedElements };
+}
+
+function migrateElementMasks({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) return element;
+
+ const masksValue = element.masks;
+ if (!Array.isArray(masksValue)) return element;
+
+ const migratedMasks = masksValue.map((mask) => migrateMask({ mask }));
+
+ return { ...element, masks: migratedMasks };
+}
+
+function migrateMask({ mask }: { mask: unknown }): unknown {
+ if (!isRecord(mask)) return mask;
+
+ const params = isRecord(mask.params) ? { ...mask.params } : {};
+ const result = { ...mask };
+
+ result.params = {
+ ...params,
+ feather: typeof mask.feather === "number" ? mask.feather : 0,
+ inverted: typeof mask.inverted === "boolean" ? mask.inverted : false,
+ strokeColor:
+ isRecord(mask.stroke) && typeof mask.stroke.color === "string"
+ ? mask.stroke.color
+ : "#ffffff",
+ strokeWidth:
+ isRecord(mask.stroke) && typeof mask.stroke.width === "number"
+ ? mask.stroke.width
+ : 0,
+ };
+
+ delete (result as Record<string, unknown>).feather;
+ delete (result as Record<string, unknown>).inverted;
+ delete (result as Record<string, unknown>).stroke;
+
+ return result;
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts
new file mode 100644
index 00000000..01a3cc3e
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts
@@ -0,0 +1,106 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV13ToV14({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (typeof project.version === "number" && project.version >= 14) {
+ return { project, skipped: true, reason: "already v14" };
+ }
+
+ const migratedProject = migrateProjectSplitMasks({ project });
+
+ return {
+ project: { ...migratedProject, version: 14 },
+ skipped: false,
+ };
+}
+
+function migrateProjectSplitMasks({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) return project;
+
+ const migratedScenes = scenesValue.map((scene) =>
+ migrateSceneSplitMasks({ scene }),
+ );
+
+ return { ...project, scenes: migratedScenes };
+}
+
+function migrateSceneSplitMasks({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) return scene;
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) return scene;
+
+ const migratedTracks = tracksValue.map((track) =>
+ migrateTrackSplitMasks({ track }),
+ );
+
+ return { ...scene, tracks: migratedTracks };
+}
+
+function migrateTrackSplitMasks({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) return track;
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) return track;
+
+ const migratedElements = elementsValue.map((element) =>
+ migrateElementSplitMasks({ element }),
+ );
+
+ return { ...track, elements: migratedElements };
+}
+
+function migrateElementSplitMasks({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) return element;
+
+ const masksValue = element.masks;
+ if (!Array.isArray(masksValue)) return element;
+
+ const migratedMasks = masksValue.map((mask) => migrateSplitMask({ mask }));
+
+ return { ...element, masks: migratedMasks };
+}
+
+/**
+ * Converts split mask params from position-along-normal to explicit (x, y) anchor.
+ *
+ * Old: center = (cx + (position-0.5)*W*cos(rot), cy + (position-0.5)*W*sin(rot))
+ * New: center = (cx + x*W, cy + y*W)
+ *
+ * Conversion: x = (position-0.5)*cos(rot), y = (position-0.5)*sin(rot)
+ */
+function migrateSplitMask({ mask }: { mask: unknown }): unknown {
+ if (!isRecord(mask)) return mask;
+ if (mask.type !== "split") return mask;
+
+ const params = isRecord(mask.params) ? mask.params : {};
+ const position = typeof params.position === "number" ? params.position : 0.5;
+ const rotation = typeof params.rotation === "number" ? params.rotation : 0;
+
+ const angleRad = (rotation * Math.PI) / 180;
+ const x = (position - 0.5) * Math.cos(angleRad);
+ const y = (position - 0.5) * Math.sin(angleRad);
+
+ const { position: _removed, ...restParams } = params as Record<
+ string,
+ unknown
+ > & { position: unknown };
+
+ return {
+ ...mask,
+ params: { ...restParams, x, y },
+ };
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts b/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts
new file mode 100644
index 00000000..beef05d4
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts
@@ -0,0 +1,67 @@
+import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV14ToV15({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (typeof project.version === "number" && project.version >= 15) {
+ return { project, skipped: true, reason: "already v15" };
+ }
+
+ const migratedProject = backfillStickerIntrinsicDimensions({ project });
+
+ return {
+ project: { ...migratedProject, version: 15 },
+ skipped: false,
+ };
+}
+
+function backfillStickerIntrinsicDimensions({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) return project;
+
+ const migratedScenes = scenesValue.map((scene) => {
+ if (!isRecord(scene)) return scene;
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) return scene;
+
+ const migratedTracks = tracksValue.map((track) => {
+ if (!isRecord(track)) return track;
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) return track;
+
+ const migratedElements = elementsValue.map((element) => {
+ if (!isRecord(element)) return element;
+ if (element.type !== "sticker") return element;
+ if (
+ typeof element.intrinsicWidth === "number" &&
+ typeof element.intrinsicHeight === "number"
+ ) {
+ return element;
+ }
+ return {
+ ...element,
+ intrinsicWidth: STICKER_INTRINSIC_SIZE_FALLBACK,
+ intrinsicHeight: STICKER_INTRINSIC_SIZE_FALLBACK,
+ };
+ });
+
+ return { ...track, elements: migratedElements };
+ });
+
+ return { ...scene, tracks: migratedTracks };
+ });
+
+ return { ...project, scenes: migratedScenes };
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts b/apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts
new file mode 100644
index 00000000..15e2f3bb
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts
@@ -0,0 +1,60 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV15ToV16({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (typeof project.version === "number" && project.version >= 16) {
+ return { project, skipped: true, reason: "already v16" };
+ }
+
+ return {
+ project: {
+ ...renameStickerTracksToGraphic({ project }),
+ version: 16,
+ },
+ skipped: false,
+ };
+}
+
+function renameStickerTracksToGraphic({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) {
+ return project;
+ }
+
+ const migratedScenes = scenesValue.map((scene) => {
+ if (!isRecord(scene) || !Array.isArray(scene.tracks)) {
+ return scene;
+ }
+
+ return {
+ ...scene,
+ tracks: scene.tracks.map((track) => {
+ if (!isRecord(track) || track.type !== "sticker") {
+ return track;
+ }
+
+ return {
+ ...track,
+ type: "graphic",
+ };
+ }),
+ };
+ });
+
+ return {
+ ...project,
+ scenes: migratedScenes,
+ };
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts b/apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts
new file mode 100644
index 00000000..7fec70e9
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts
@@ -0,0 +1,107 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV16ToV17({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (typeof project.version === "number" && project.version >= 17) {
+ return { project, skipped: true, reason: "already v17" };
+ }
+
+ return {
+ project: {
+ ...backfillMaskStrokeAlign({ project }),
+ version: 17,
+ },
+ skipped: false,
+ };
+}
+
+function backfillMaskStrokeAlign({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) {
+ return project;
+ }
+
+ const migratedScenes = scenesValue.map((scene) => {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) {
+ return scene;
+ }
+
+ const migratedTracks = tracksValue.map((track) => {
+ if (!isRecord(track)) {
+ return track;
+ }
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) {
+ return track;
+ }
+
+ const migratedElements = elementsValue.map((element) => {
+ if (!isRecord(element)) {
+ return element;
+ }
+
+ const masksValue = element.masks;
+ if (!Array.isArray(masksValue)) {
+ return element;
+ }
+
+ const migratedMasks = masksValue.map((mask) => {
+ if (!isRecord(mask)) {
+ return mask;
+ }
+
+ const paramsValue = mask.params;
+ if (!isRecord(paramsValue) || typeof paramsValue.strokeAlign === "string") {
+ return mask;
+ }
+
+ return {
+ ...mask,
+ params: {
+ ...paramsValue,
+ strokeAlign: "center",
+ },
+ };
+ });
+
+ return {
+ ...element,
+ masks: migratedMasks,
+ };
+ });
+
+ return {
+ ...track,
+ elements: migratedElements,
+ };
+ });
+
+ return {
+ ...scene,
+ tracks: migratedTracks,
+ };
+ });
+
+ return {
+ ...project,
+ scenes: migratedScenes,
+ };
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts b/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts
new file mode 100644
index 00000000..31a60121
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts
@@ -0,0 +1,105 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { clampDb } from "@/lib/timeline/audio-state";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV17ToV18({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (typeof project.version === "number" && project.version >= 18) {
+ return { project, skipped: true, reason: "already v18" };
+ }
+
+ return {
+ project: {
+ ...migrateElementVolumes({ project }),
+ version: 18,
+ },
+ skipped: false,
+ };
+}
+
+function migrateElementVolumes({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) {
+ return project;
+ }
+
+ const migratedScenes = scenesValue.map((scene) => {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) {
+ return scene;
+ }
+
+ const migratedTracks = tracksValue.map((track) => {
+ if (!isRecord(track)) {
+ return track;
+ }
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) {
+ return track;
+ }
+
+ const migratedElements = elementsValue.map((element) => {
+ if (!isRecord(element)) {
+ return element;
+ }
+
+ if (element.type !== "audio" && element.type !== "video") {
+ return element;
+ }
+
+ const legacyVolume = element.volume;
+ return {
+ ...element,
+ volume:
+ typeof legacyVolume === "number"
+ ? linearGainToDb(legacyVolume)
+ : 0,
+ };
+ });
+
+ return {
+ ...track,
+ elements: migratedElements,
+ };
+ });
+
+ return {
+ ...scene,
+ tracks: migratedTracks,
+ };
+ });
+
+ return {
+ ...project,
+ scenes: migratedScenes,
+ };
+}
+
+function linearGainToDb(gain: number): number {
+ if (!Number.isFinite(gain)) {
+ return 0;
+ }
+
+ if (gain <= 0) {
+ return VOLUME_DB_MIN;
+ }
+
+ return clampDb(20 * Math.log10(gain));
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts b/apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts
new file mode 100644
index 00000000..1946b829
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts
@@ -0,0 +1,46 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV18ToV19({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (typeof project.version === "number" && project.version >= 19) {
+ return { project, skipped: true, reason: "already v19" };
+ }
+
+ return {
+ project: {
+ ...migrateCanvasSizeSettings({ project }),
+ version: 19,
+ },
+ skipped: false,
+ };
+}
+
+function migrateCanvasSizeSettings({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const settingsValue = project.settings;
+ if (!isRecord(settingsValue)) {
+ return project;
+ }
+
+ const migratedSettings: Record<string, unknown> = {
+ ...settingsValue,
+ canvasSizeMode: "preset",
+ lastCustomCanvasSize: null,
+ };
+
+ return {
+ ...project,
+ settings: migratedSettings,
+ };
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts b/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts
index 094b8bd8..0807d4fb 100644
--- a/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts
@@ -1,5 +1,3 @@
-import { getProjectDurationFromScenes } from "@/lib/scenes";
-import type { TScene } from "@/types/timeline";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
@@ -18,7 +16,7 @@ export function transformProjectV2ToV3({
}
const scenes = getScenes({ project });
- const duration = getProjectDurationFromScenes({ scenes });
+ const duration = getDurationFromScenes({ scenes });
const metadataValue = project.metadata;
const metadata = isRecord(metadataValue)
@@ -36,13 +34,42 @@ export function transformProjectV2ToV3({
export { getProjectId } from "./utils";
-function getScenes({ project }: { project: ProjectRecord }): TScene[] {
+function getScenes({ project }: { project: ProjectRecord }): unknown[] {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) {
return [];
}
- return scenesValue.filter(isRecord) as unknown as TScene[];
+ return scenesValue.filter(isRecord);
+}
+
+function getDurationFromScenes({ scenes }: { scenes: unknown[] }): number {
+ const mainScene =
+ scenes.find(
+ (s): s is Record<string, unknown> =>
+ isRecord(s) && s.isMain === true,
+ ) ??
+ scenes.find(isRecord) ??
+ null;
+
+ if (!mainScene || !Array.isArray(mainScene.tracks)) {
+ return 0;
+ }
+
+ let maxEnd = 0;
+ for (const track of mainScene.tracks) {
+ if (!isRecord(track) || !Array.isArray(track.elements)) continue;
+ for (const element of track.elements) {
+ if (!isRecord(element)) continue;
+ const startTime =
+ typeof element.startTime === "number" ? element.startTime : 0;
+ const duration =
+ typeof element.duration === "number" ? element.duration : 0;
+ maxEnd = Math.max(maxEnd, startTime + duration);
+ }
+ }
+
+ return maxEnd;
}
function isV3Project({ project }: { project: ProjectRecord }): boolean {
diff --git a/apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts b/apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts
new file mode 100644
index 00000000..52d13295
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts
@@ -0,0 +1,21 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId } from "./utils";
+
+export function transformProjectV9ToV10({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (typeof project.version === "number" && project.version >= 10) {
+ return { project, skipped: true, reason: "already v10" };
+ }
+
+ return {
+ project: { ...project, version: 10 },
+ skipped: false,
+ };
+}
diff --git a/apps/web/src/services/storage/migrations/v10-to-v11.ts b/apps/web/src/services/storage/migrations/v10-to-v11.ts
new file mode 100644
index 00000000..68439fbe
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v10-to-v11.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV10ToV11 } from "./transformers/v10-to-v11";
+
+export class V10toV11Migration extends StorageMigration {
+ from = 10;
+ to = 11;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV10ToV11({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v11-to-v12.ts b/apps/web/src/services/storage/migrations/v11-to-v12.ts
new file mode 100644
index 00000000..5efedbef
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v11-to-v12.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV11ToV12 } from "./transformers/v11-to-v12";
+
+export class V11toV12Migration extends StorageMigration {
+ from = 11;
+ to = 12;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV11ToV12({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v12-to-v13.ts b/apps/web/src/services/storage/migrations/v12-to-v13.ts
new file mode 100644
index 00000000..5c6e05fc
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v12-to-v13.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV12ToV13 } from "./transformers/v12-to-v13";
+
+export class V12toV13Migration extends StorageMigration {
+ from = 12;
+ to = 13;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV12ToV13({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v13-to-v14.ts b/apps/web/src/services/storage/migrations/v13-to-v14.ts
new file mode 100644
index 00000000..fbc9fc5f
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v13-to-v14.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV13ToV14 } from "./transformers/v13-to-v14";
+
+export class V13toV14Migration extends StorageMigration {
+ from = 13;
+ to = 14;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV13ToV14({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v14-to-v15.ts b/apps/web/src/services/storage/migrations/v14-to-v15.ts
new file mode 100644
index 00000000..1d18423f
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v14-to-v15.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV14ToV15 } from "./transformers/v14-to-v15";
+
+export class V14toV15Migration extends StorageMigration {
+ from = 14;
+ to = 15;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV14ToV15({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v15-to-v16.ts b/apps/web/src/services/storage/migrations/v15-to-v16.ts
new file mode 100644
index 00000000..800c9c07
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v15-to-v16.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV15ToV16 } from "./transformers/v15-to-v16";
+
+export class V15toV16Migration extends StorageMigration {
+ from = 15;
+ to = 16;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV15ToV16({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v16-to-v17.ts b/apps/web/src/services/storage/migrations/v16-to-v17.ts
new file mode 100644
index 00000000..f4029db6
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v16-to-v17.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV16ToV17 } from "./transformers/v16-to-v17";
+
+export class V16toV17Migration extends StorageMigration {
+ from = 16;
+ to = 17;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV16ToV17({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v17-to-v18.ts b/apps/web/src/services/storage/migrations/v17-to-v18.ts
new file mode 100644
index 00000000..75beb36b
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v17-to-v18.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV17ToV18 } from "./transformers/v17-to-v18";
+
+export class V17toV18Migration extends StorageMigration {
+ from = 17;
+ to = 18;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV17ToV18({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v18-to-v19.ts b/apps/web/src/services/storage/migrations/v18-to-v19.ts
new file mode 100644
index 00000000..166d9855
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v18-to-v19.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV18ToV19 } from "./transformers/v18-to-v19";
+
+export class V18toV19Migration extends StorageMigration {
+ from = 18;
+ to = 19;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV18ToV19({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v9-to-v10.ts b/apps/web/src/services/storage/migrations/v9-to-v10.ts
new file mode 100644
index 00000000..801096fe
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v9-to-v10.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV9ToV10 } from "./transformers/v9-to-v10";
+
+export class V9toV10Migration extends StorageMigration {
+ from = 9;
+ to = 10;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV9ToV10({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/quota.ts b/apps/web/src/services/storage/quota.ts
new file mode 100644
index 00000000..1eb6028e
--- /dev/null
+++ b/apps/web/src/services/storage/quota.ts
@@ -0,0 +1,145 @@
+const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
+
+export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024;
+
+export interface StorageQuotaStatus {
+ quotaBytes: number | null;
+ usageBytes: number | null;
+ headroomBytes: number | null;
+ availableBytes: number | null;
+}
+
+export interface StorageCapacityCheckResult {
+ canStore: boolean;
+ reason: "enough-space" | "insufficient-space" | "estimate-unavailable";
+ availableBytes: number | null;
+}
+
+function normalizeByteValue({ value }: { value: unknown }): number | null {
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
+ return null;
+ }
+
+ return value;
+}
+
+export function formatStorageBytes({ bytes }: { bytes: number }): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) {
+ return "0 B";
+ }
+
+ let value = bytes;
+ let unitIndex = 0;
+
+ while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
+ value /= 1024;
+ unitIndex += 1;
+ }
+
+ const precision = value >= 10 || unitIndex === 0 ? 0 : 1;
+ return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`;
+}
+
+export async function readStorageQuotaStatus(): Promise<StorageQuotaStatus> {
+ if (
+ typeof navigator === "undefined" ||
+ !navigator.storage ||
+ typeof navigator.storage.estimate !== "function"
+ ) {
+ return {
+ quotaBytes: null,
+ usageBytes: null,
+ headroomBytes: null,
+ availableBytes: null,
+ };
+ }
+
+ const estimate = await navigator.storage.estimate();
+ const quotaBytes = normalizeByteValue({ value: estimate.quota });
+ const usageBytes = normalizeByteValue({ value: estimate.usage });
+
+ if (quotaBytes === null || usageBytes === null) {
+ return {
+ quotaBytes,
+ usageBytes,
+ headroomBytes: null,
+ availableBytes: null,
+ };
+ }
+
+ const headroomBytes = Math.max(quotaBytes - usageBytes, 0);
+ const availableBytes = Math.max(
+ headroomBytes - STORAGE_HEADROOM_RESERVE_BYTES,
+ 0,
+ );
+
+ return {
+ quotaBytes,
+ usageBytes,
+ headroomBytes,
+ availableBytes,
+ };
+}
+
+export function evaluateStorageCapacity({
+ requiredBytes,
+ quotaStatus,
+}: {
+ requiredBytes: number;
+ quotaStatus: StorageQuotaStatus;
+}): StorageCapacityCheckResult {
+ if (quotaStatus.availableBytes === null) {
+ return {
+ canStore: true,
+ reason: "estimate-unavailable",
+ availableBytes: null,
+ };
+ }
+
+ if (requiredBytes > quotaStatus.availableBytes) {
+ return {
+ canStore: false,
+ reason: "insufficient-space",
+ availableBytes: quotaStatus.availableBytes,
+ };
+ }
+
+ return {
+ canStore: true,
+ reason: "enough-space",
+ availableBytes: quotaStatus.availableBytes,
+ };
+}
+
+export class StorageQuotaExceededError extends Error {
+ requiredBytes: number;
+
+ constructor({ requiredBytes }: { requiredBytes: number }) {
+ super(
+ `Not enough browser storage to save a ${formatStorageBytes({ bytes: requiredBytes })} file.`,
+ );
+
+ this.name = "StorageQuotaExceededError";
+ this.requiredBytes = requiredBytes;
+ }
+}
+
+export function isStorageQuotaExceededError({
+ error,
+}: {
+ error: unknown;
+}): boolean {
+ if (error instanceof StorageQuotaExceededError) {
+ return true;
+ }
+
+ if (!(error instanceof Error)) {
+ return false;
+ }
+
+ return (
+ error.name === "QuotaExceededError" ||
+ error.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
+ error.message.toLowerCase().includes("quota")
+ );
+}
diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts
index aac152da..3a014894 100644
--- a/apps/web/src/services/storage/service.ts
+++ b/apps/web/src/services/storage/service.ts
@@ -1,20 +1,27 @@
-import type { TProject, TProjectMetadata } from "@/types/project";
+import type { TProject, TProjectMetadata } from "@/lib/project/types";
import { getProjectDurationFromScenes } from "@/lib/scenes";
-import type { MediaAsset } from "@/types/assets";
+import type { MediaAsset } from "@/lib/media/types";
import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter";
+import {
+ type StorageCapacityCheckResult,
+ StorageQuotaExceededError,
+ evaluateStorageCapacity,
+ isStorageQuotaExceededError,
+ readStorageQuotaStatus,
+} from "./quota";
import type {
MediaAssetData,
StorageConfig,
SerializedProject,
SerializedScene,
} from "./types";
-import type { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds";
+import type { SavedSoundsData, SavedSound, SoundEffect } from "@/lib/sounds/types";
import {
migrations,
runStorageMigrations,
} from "@/services/storage/migrations";
-import type { Bookmark, TimelineTrack, TScene } from "@/types/timeline";
+import type { Bookmark, TimelineTrack, TScene } from "@/lib/timeline";
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
if (!Array.isArray(raw)) return [];
@@ -90,6 +97,22 @@ class StorageService {
return { mediaMetadataAdapter, mediaAssetsAdapter };
}
+ async canStoreFile({
+ size,
+ }: {
+ size: number;
+ }): Promise<StorageCapacityCheckResult> {
+ const quotaStatus = await readStorageQuotaStatus();
+ return evaluateStorageCapacity({
+ requiredBytes: size,
+ quotaStatus,
+ });
+ }
+
+ isQuotaExceededError({ error }: { error: unknown }): boolean {
+ return isStorageQuotaExceededError({ error });
+ }
+
private stripAudioBuffers({
tracks,
}: {
@@ -238,8 +261,6 @@ class StorageService {
const { mediaMetadataAdapter, mediaAssetsAdapter } =
this.getProjectMediaAdapters({ projectId });
- await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file);
-
const metadata: MediaAssetData = {
id: mediaAsset.id,
name: mediaAsset.name,
@@ -253,7 +274,24 @@ class StorageService {
ephemeral: mediaAsset.ephemeral,
};
- await mediaMetadataAdapter.set(mediaAsset.id, metadata);
+ try {
+ await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file);
+ await mediaMetadataAdapter.set(mediaAsset.id, metadata);
+ } catch (error) {
+ try {
+ await mediaAssetsAdapter.remove(mediaAsset.id);
+ } catch {
+ // Ignore cleanup failures so the original storage error is preserved.
+ }
+
+ if (this.isQuotaExceededError({ error })) {
+ throw new StorageQuotaExceededError({
+ requiredBytes: mediaAsset.file.size,
+ });
+ }
+
+ throw error;
+ }
}
async loadMediaAsset({
diff --git a/apps/web/src/services/storage/types.ts b/apps/web/src/services/storage/types.ts
index 7481c73c..1dcf6545 100644
--- a/apps/web/src/services/storage/types.ts
+++ b/apps/web/src/services/storage/types.ts
@@ -1,10 +1,10 @@
-import type { MediaType } from "@/types/assets";
+import type { MediaType } from "@/lib/media/types";
import type {
TProject,
TProjectMetadata,
TTimelineViewState,
-} from "@/types/project";
-import type { TScene } from "@/types/timeline";
+} from "@/lib/project/types";
+import type { TScene } from "@/lib/timeline";
export interface StorageAdapter<T> {
get(key: string): Promise<T | null>;
@@ -24,6 +24,7 @@ export interface MediaAssetData {
height?: number;
duration?: number;
fps?: number;
+ hasAudio?: boolean;
ephemeral?: boolean;
thumbnailUrl?: string;
}
diff --git a/apps/web/src/services/transcription/service.ts b/apps/web/src/services/transcription/service.ts
index c34a82ce..ab88106c 100644
--- a/apps/web/src/services/transcription/service.ts
+++ b/apps/web/src/services/transcription/service.ts
@@ -3,7 +3,7 @@ import type {
TranscriptionResult,
TranscriptionProgress,
TranscriptionModelId,
-} from "@/types/transcription";
+} from "@/lib/transcription/types";
import {
DEFAULT_TRANSCRIPTION_MODEL,
TRANSCRIPTION_MODELS,
diff --git a/apps/web/src/services/transcription/worker.ts b/apps/web/src/services/transcription/worker.ts
index 2aa9673c..e583b2ab 100644
--- a/apps/web/src/services/transcription/worker.ts
+++ b/apps/web/src/services/transcription/worker.ts
@@ -3,7 +3,7 @@ import {
type AutomaticSpeechRecognitionPipeline,
type AutomaticSpeechRecognitionOutput,
} from "@huggingface/transformers";
-import type { TranscriptionSegment } from "@/types/transcription";
+import type { TranscriptionSegment } from "@/lib/transcription/types";
import {
DEFAULT_CHUNK_LENGTH_SECONDS,
DEFAULT_STRIDE_SECONDS,
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts
index ad351bf9..96009928 100644
--- a/apps/web/src/stores/editor-store.ts
+++ b/apps/web/src/stores/editor-store.ts
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { DEFAULT_CANVAS_PRESETS } from "@/constants/project-constants";
-import type { TCanvasSize } from "@/types/project";
+import type { TCanvasSize } from "@/lib/project/types";
interface EditorState {
isInitializing: boolean;
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts
index 906e9aa8..b4cf2703 100644
--- a/apps/web/src/stores/keybindings-store.ts
+++ b/apps/web/src/stores/keybindings-store.ts
@@ -6,10 +6,10 @@ import type { TActionWithOptionalArgs } from "@/lib/actions";
import { getDefaultShortcuts } from "@/lib/actions";
import { isTypableDOMElement } from "@/utils/browser";
import { isAppleDevice } from "@/utils/platform";
-import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
+import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding";
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
-export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
+const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
export interface KeybindingConflict {
key: ShortcutKey;
@@ -20,7 +20,9 @@ export interface KeybindingConflict {
interface KeybindingsState {
keybindings: KeybindingConfig;
isCustomized: boolean;
- keybindingsEnabled: boolean;
+ overlayDepth: number;
+ openOverlayIds: string[];
+ isLoadingProject: boolean;
isRecording: boolean;
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void;
@@ -28,8 +30,9 @@ interface KeybindingsState {
resetToDefaults: () => void;
importKeybindings: (config: KeybindingConfig) => void;
exportKeybindings: () => KeybindingConfig;
- enableKeybindings: () => void;
- disableKeybindings: () => void;
+ openOverlay: (overlayId: string, source: string) => void;
+ closeOverlay: (overlayId: string, source: string) => void;
+ setLoadingProject: (loading: boolean) => void;
setIsRecording: (isRecording: boolean) => void;
validateKeybinding: (
key: ShortcutKey,
@@ -48,9 +51,112 @@ export const useKeybindingsStore = create<KeybindingsState>()(
(set, get) => ({
keybindings: { ...defaultKeybindings },
isCustomized: false,
- keybindingsEnabled: true,
+ overlayDepth: 0,
+ openOverlayIds: [],
+ isLoadingProject: false,
isRecording: false,
+ openOverlay: (overlayId, source) =>
+ set((s) => {
+ const openOverlayIds = s.openOverlayIds.includes(overlayId)
+ ? s.openOverlayIds
+ : [...s.openOverlayIds, overlayId];
+ const nextOverlayDepth = openOverlayIds.length;
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H2",
+ location: "keybindings-store.ts:openOverlay",
+ message: "Overlay depth incremented",
+ data: {
+ source,
+ overlayId,
+ overlayDepth: s.overlayDepth,
+ nextOverlayDepth,
+ openOverlayIds,
+ },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ return {
+ openOverlayIds,
+ overlayDepth: nextOverlayDepth,
+ };
+ }),
+ closeOverlay: (overlayId, source) =>
+ set((s) => {
+ const openOverlayIds = s.openOverlayIds.filter(
+ (id) => id !== overlayId,
+ );
+ const nextOverlayDepth = openOverlayIds.length;
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H2",
+ location: "keybindings-store.ts:closeOverlay",
+ message: "Overlay depth decremented",
+ data: {
+ source,
+ overlayId,
+ overlayDepth: s.overlayDepth,
+ nextOverlayDepth,
+ openOverlayIds,
+ },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ return {
+ openOverlayIds,
+ overlayDepth: nextOverlayDepth,
+ };
+ }),
+ setLoadingProject: (loading) => {
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H2",
+ location: "keybindings-store.ts:setLoadingProject",
+ message: "Loading gate updated",
+ data: { loading },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ set({ isLoadingProject: loading });
+ },
+
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => {
set((state) => {
const newKeybindings = { ...state.keybindings };
@@ -82,17 +188,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
});
},
- enableKeybindings: () => {
- set({ keybindingsEnabled: true });
- },
-
- disableKeybindings: () => {
- set({ keybindingsEnabled: false });
- },
-
- importKeybindings: (config: KeybindingConfig) => {
- for (const [key] of Object.entries(config)) {
- if (typeof key !== "string" || key.length === 0) {
+ importKeybindings: (config: KeybindingConfig) => {
+ for (const [key] of Object.entries(config)) {
+ if (typeof key !== "string" || key.length === 0) {
throw new Error(`Invalid key format: ${key}`);
}
}
@@ -124,6 +222,27 @@ export const useKeybindingsStore = create<KeybindingsState>()(
return null;
},
setIsRecording: (isRecording: boolean) => {
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "initial",
+ hypothesisId: "H2",
+ location: "keybindings-store.ts:setIsRecording",
+ message: "Recording gate updated",
+ data: { isRecording },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
set({ isRecording });
},
@@ -169,7 +288,10 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
return `${modifierKey}+${key}` as ShortcutKey;
}
- if (isDOMElement(target) && isTypableDOMElement({ element: target as HTMLElement }))
+ if (
+ isDOMElement(target) &&
+ isTypableDOMElement({ element: target as HTMLElement })
+ )
return null;
return `${key}` as ShortcutKey;
diff --git a/apps/web/src/stores/keybindings/migrations/index.ts b/apps/web/src/stores/keybindings/migrations/index.ts
index e6641f41..330c7df7 100644
--- a/apps/web/src/stores/keybindings/migrations/index.ts
+++ b/apps/web/src/stores/keybindings/migrations/index.ts
@@ -1,6 +1,7 @@
import { v2ToV3 } from "./v2-to-v3";
import { v3ToV4 } from "./v3-to-v4";
import { v4ToV5 } from "./v4-to-v5";
+import { v5ToV6 } from "./v5-to-v6";
type MigrationFn = ({ state }: { state: unknown }) => unknown;
@@ -8,9 +9,10 @@ const migrations: Record<number, MigrationFn> = {
2: v2ToV3,
3: v3ToV4,
4: v4ToV5,
+ 5: v5ToV6,
};
-export const CURRENT_VERSION = 5;
+export const CURRENT_VERSION = 6;
export function runMigrations({
state,
diff --git a/apps/web/src/stores/keybindings/migrations/v2-to-v3.ts b/apps/web/src/stores/keybindings/migrations/v2-to-v3.ts
index 1517b52b..da0df588 100644
--- a/apps/web/src/stores/keybindings/migrations/v2-to-v3.ts
+++ b/apps/web/src/stores/keybindings/migrations/v2-to-v3.ts
@@ -1,4 +1,4 @@
-import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding";
+import type { KeybindingConfig, ShortcutKey } from "@/lib/actions/keybinding";
import type { TActionWithOptionalArgs } from "@/lib/actions";
interface V2State {
diff --git a/apps/web/src/stores/keybindings/migrations/v3-to-v4.ts b/apps/web/src/stores/keybindings/migrations/v3-to-v4.ts
index b53e082f..be3b4a64 100644
--- a/apps/web/src/stores/keybindings/migrations/v3-to-v4.ts
+++ b/apps/web/src/stores/keybindings/migrations/v3-to-v4.ts
@@ -1,6 +1,6 @@
import type { TActionWithOptionalArgs } from "@/lib/actions";
-import type { ShortcutKey } from "@/types/keybinding";
-import type { KeybindingConfig } from "@/types/keybinding";
+import type { ShortcutKey } from "@/lib/actions/keybinding";
+import type { KeybindingConfig } from "@/lib/actions/keybinding";
interface V3State {
keybindings: KeybindingConfig;
diff --git a/apps/web/src/stores/keybindings/migrations/v4-to-v5.ts b/apps/web/src/stores/keybindings/migrations/v4-to-v5.ts
index 2ab39247..4e23e0f4 100644
--- a/apps/web/src/stores/keybindings/migrations/v4-to-v5.ts
+++ b/apps/web/src/stores/keybindings/migrations/v4-to-v5.ts
@@ -1,4 +1,4 @@
-import type { KeybindingConfig } from "@/types/keybinding";
+import type { KeybindingConfig } from "@/lib/actions/keybinding";
interface V4State {
keybindings: KeybindingConfig;
diff --git a/apps/web/src/stores/keybindings/migrations/v5-to-v6.ts b/apps/web/src/stores/keybindings/migrations/v5-to-v6.ts
new file mode 100644
index 00000000..3545815d
--- /dev/null
+++ b/apps/web/src/stores/keybindings/migrations/v5-to-v6.ts
@@ -0,0 +1,17 @@
+import type { KeybindingConfig } from "@/lib/actions/keybinding";
+
+interface V5State {
+ keybindings: KeybindingConfig;
+ isCustomized: boolean;
+}
+
+export function v5ToV6({ state }: { state: unknown }): unknown {
+ const v5 = state as V5State;
+ const keybindings = { ...v5.keybindings };
+
+ if (keybindings.escape === "deselect-all") {
+ keybindings.escape = "cancel-interaction";
+ }
+
+ return { ...v5, keybindings };
+}
diff --git a/apps/web/src/stores/preview-store.ts b/apps/web/src/stores/preview-store.ts
index 03b28fe2..cd6488b3 100644
--- a/apps/web/src/stores/preview-store.ts
+++ b/apps/web/src/stores/preview-store.ts
@@ -1,20 +1,28 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-import type { TPlatformLayout } from "@/types/editor";
-
-interface LayoutGuideSettings {
- platform: TPlatformLayout | null;
-}
+import { isGuideId, type GuideId } from "@/lib/guides";
+import { DEFAULT_GRID_CONFIG } from "@/constants/guide-constants";
+import type { GridConfig } from "@/lib/guides/types";
interface PreviewOverlaysState {
bookmarks: boolean;
}
+interface PersistedPreviewState {
+ activeGuide?: string | null;
+ layoutGuide?: {
+ platform?: string | null;
+ };
+ overlays?: PreviewOverlaysState;
+ gridConfig?: GridConfig;
+}
+
interface PreviewState {
- layoutGuide: LayoutGuideSettings;
+ activeGuide: GuideId | null;
overlays: PreviewOverlaysState;
- setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void;
- toggleLayoutGuide: (platform: TPlatformLayout) => void;
+ gridConfig: GridConfig;
+ toggleGuide: (guideId: GuideId) => void;
+ setGridConfig: (config: Partial<GridConfig>) => void;
setOverlayVisibility: ({
overlay,
isVisible,
@@ -33,24 +41,33 @@ const DEFAULT_PREVIEW_OVERLAYS: PreviewOverlaysState = {
bookmarks: true,
};
+function getPersistedActiveGuide(
+ state: PersistedPreviewState | undefined,
+): GuideId | null {
+ const persistedGuide =
+ state?.activeGuide ?? state?.layoutGuide?.platform ?? null;
+
+ if (typeof persistedGuide !== "string") {
+ return null;
+ }
+
+ return isGuideId(persistedGuide) ? persistedGuide : null;
+}
+
export const usePreviewStore = create<PreviewState>()(
persist(
(set) => ({
- layoutGuide: { platform: null },
+ activeGuide: null,
overlays: DEFAULT_PREVIEW_OVERLAYS,
- setLayoutGuide: (settings) => {
+ gridConfig: DEFAULT_GRID_CONFIG,
+ toggleGuide: (guideId) => {
set((state) => ({
- layoutGuide: {
- ...state.layoutGuide,
- ...settings,
- },
+ activeGuide: state.activeGuide === guideId ? null : guideId,
}));
},
- toggleLayoutGuide: (platform) => {
+ setGridConfig: (config) => {
set((state) => ({
- layoutGuide: {
- platform: state.layoutGuide.platform === platform ? null : platform,
- },
+ gridConfig: { ...state.gridConfig, ...config },
}));
},
setOverlayVisibility: ({ overlay, isVisible }) => {
@@ -72,22 +89,23 @@ export const usePreviewStore = create<PreviewState>()(
}),
{
name: "preview-settings",
- version: 2,
+ version: 4,
migrate: (persistedState) => {
- const state = persistedState as
- | {
- layoutGuide?: LayoutGuideSettings;
- overlays?: PreviewOverlaysState;
- }
- | undefined;
+ const state = persistedState as PersistedPreviewState | undefined;
+
return {
- layoutGuide: state?.layoutGuide ?? { platform: null },
+ activeGuide: getPersistedActiveGuide(state),
overlays: state?.overlays ?? DEFAULT_PREVIEW_OVERLAYS,
+ gridConfig: {
+ rows: state?.gridConfig?.rows ?? DEFAULT_GRID_CONFIG.rows,
+ cols: state?.gridConfig?.cols ?? DEFAULT_GRID_CONFIG.cols,
+ },
};
},
partialize: (state) => ({
- layoutGuide: state.layoutGuide,
+ activeGuide: state.activeGuide,
overlays: state.overlays,
+ gridConfig: state.gridConfig,
}),
},
),
diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts
index 8edbcc97..1c58bd69 100644
--- a/apps/web/src/stores/sounds-store.ts
+++ b/apps/web/src/stores/sounds-store.ts
@@ -1,5 +1,5 @@
import { create } from "zustand";
-import type { SoundEffect, SavedSound } from "@/types/sounds";
+import type { SoundEffect, SavedSound } from "@/lib/sounds/types";
import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { EditorCore } from "@/core";
diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts
index 3f713b3d..7a678084 100644
--- a/apps/web/src/stores/stickers-store.ts
+++ b/apps/web/src/stores/stickers-store.ts
@@ -1,16 +1,20 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-import { EditorCore } from "@/core";
-import { searchStickers as searchStickersFromProviders } from "@/lib/stickers";
-import type { StickerSearchResult } from "@/lib/stickers";
-import { buildStickerElement } from "@/lib/timeline/element-utils";
+import {
+ browseAll,
+ browseCategory,
+ searchAll,
+ searchStickers as searchStickersFromProviders,
+} from "@/lib/stickers";
+import type { StickerBrowseResult, StickerSearchResult } from "@/lib/stickers";
import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
-import type { StickerCategory } from "@/types/stickers";
+import type { StickerCategory } from "@/lib/stickers/types";
import { registerDefaultStickerProviders } from "@/lib/stickers/providers";
-import { hasProvider } from "@/lib/stickers/registry";
+import { stickersRegistry } from "@/lib/stickers/registry";
import { parseStickerId } from "@/lib/stickers/sticker-id";
const MAX_RECENT_STICKERS = 50;
+let browseRequestVersion = 0;
function isValidStickerId(value: unknown): value is string {
if (typeof value !== "string") {
@@ -19,7 +23,7 @@ function isValidStickerId(value: unknown): value is string {
try {
const parsed = parseStickerId({ stickerId: value });
- return hasProvider({ providerId: parsed.providerId });
+ return stickersRegistry.has(parsed.providerId);
} catch {
return false;
}
@@ -60,20 +64,15 @@ interface StickersStore {
selectedCategory: StickerCategory;
viewMode: ViewMode;
searchResults: StickerSearchResult | null;
+ browseContent: StickerBrowseResult | null;
recentStickers: string[];
isSearching: boolean;
- addingSticker: string | null;
+ isBrowsing: boolean;
setSearchQuery: ({ query }: { query: string }) => void;
setSelectedCategory: ({ category }: { category: StickerCategory }) => void;
searchStickers: ({ query }: { query: string }) => Promise<void>;
- addStickerToTimeline: ({
- stickerId,
- name,
- }: {
- stickerId: string;
- name?: string;
- }) => void;
+ browseStickers: () => Promise<void>;
addToRecentStickers: ({ stickerId }: { stickerId: string }) => void;
clearRecentStickers: () => void;
}
@@ -86,22 +85,34 @@ export const useStickersStore = create<StickersStore>()(
viewMode: "browse",
searchResults: null,
+ browseContent: null,
recentStickers: [],
isSearching: false,
- addingSticker: null,
+ isBrowsing: false,
setSearchQuery: ({ query }) => set({ searchQuery: query }),
- setSelectedCategory: ({ category }) =>
- set({
- selectedCategory: category in STICKER_CATEGORIES ? category : "all",
- viewMode: "browse",
- }),
+ setSelectedCategory: ({ category }) => {
+ set({
+ selectedCategory: category in STICKER_CATEGORIES ? category : "all",
+ browseContent: null,
+ });
+
+ const query = get().searchQuery.trim();
+ if (query) {
+ void get().searchStickers({ query });
+ return;
+ }
+
+ void get().browseStickers();
+ },
searchStickers: async ({ query }: { query: string }) => {
- if (!query.trim()) {
+ const trimmedQuery = query.trim();
+ if (!trimmedQuery) {
set({ searchResults: null, viewMode: "browse" });
+ await get().browseStickers();
return;
}
@@ -111,12 +122,17 @@ export const useStickersStore = create<StickersStore>()(
set({ isSearching: true, viewMode: "search" });
try {
- const results = await searchStickersFromProviders({
- query,
- category: selectedCategory,
- limit: 100,
- });
- set({ searchResults: results });
+ if (selectedCategory === "all") {
+ const browseContent = await searchAll({ query: trimmedQuery });
+ set({ browseContent, searchResults: null });
+ } else {
+ const results = await searchStickersFromProviders({
+ query: trimmedQuery,
+ category: selectedCategory,
+ limit: 100,
+ });
+ set({ searchResults: results });
+ }
} catch (error) {
console.error("Search failed:", error);
set({ searchResults: null });
@@ -125,67 +141,70 @@ export const useStickersStore = create<StickersStore>()(
}
},
- addStickerToTimeline: ({
- stickerId,
- name,
- }: {
- stickerId: string;
- name?: string;
- }) => {
- set({ addingSticker: stickerId });
- try {
- const editor = EditorCore.getInstance();
- const currentTime = editor.playback.getCurrentTime();
- const tracks = editor.timeline.getTracks();
+ browseStickers: async () => {
+ const version = ++browseRequestVersion;
+ const category = get().selectedCategory;
+ const selectedCategory =
+ category in STICKER_CATEGORIES ? category : "all";
- const stickerTrack = tracks.find((t) => t.type === "sticker");
- let trackId: string;
+ set({ isBrowsing: true, viewMode: "browse" });
+ try {
+ const browseContent =
+ selectedCategory === "all"
+ ? await browseAll({
+ recentStickers: get().recentStickers,
+ })
+ : await browseCategory({
+ category: selectedCategory,
+ });
- if (stickerTrack) {
- trackId = stickerTrack.id;
- } else {
- trackId = editor.timeline.addTrack({ type: "sticker" });
- }
+ if (version !== browseRequestVersion) return;
+ set({ browseContent });
+ } catch (error) {
+ if (version !== browseRequestVersion) return;
+ console.error("Browse failed:", error);
+ set({ browseContent: null });
+ } finally {
+ if (version === browseRequestVersion) {
+ set({ isBrowsing: false });
+ }
+ }
+ },
- const element = buildStickerElement({
- stickerId,
- name,
- startTime: currentTime,
- });
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element,
- });
+ addToRecentStickers: ({ stickerId }: { stickerId: string }) => {
+ const sanitizedStickerIds = sanitizeRecentStickers({
+ recentStickers: [stickerId],
+ });
+ if (sanitizedStickerIds.length === 0) {
+ return;
+ }
- get().addToRecentStickers({ stickerId });
- } finally {
- set({ addingSticker: null });
- }
- },
+ set((state) => {
+ const recent = [
+ sanitizedStickerIds[0],
+ ...state.recentStickers.filter((s) => s !== sanitizedStickerIds[0]),
+ ];
+ return {
+ recentStickers: recent.slice(0, MAX_RECENT_STICKERS),
+ };
+ });
- addToRecentStickers: ({ stickerId }: { stickerId: string }) => {
- const sanitizedStickerIds = sanitizeRecentStickers({
- recentStickers: [stickerId],
- });
- if (sanitizedStickerIds.length === 0) {
- return;
- }
+ if (get().viewMode === "browse" && get().selectedCategory === "all") {
+ void get().browseStickers();
+ }
+ },
- set((state) => {
- const recent = [
- sanitizedStickerIds[0],
- ...state.recentStickers.filter((s) => s !== sanitizedStickerIds[0]),
- ];
- return {
- recentStickers: recent.slice(0, MAX_RECENT_STICKERS),
- };
- });
- },
+ clearRecentStickers: () => {
+ set({ recentStickers: [] });
- clearRecentStickers: () => set({ recentStickers: [] }),
+ if (get().viewMode === "browse" && get().selectedCategory === "all") {
+ void get().browseStickers();
+ }
+ },
}),
{
name: "stickers-settings",
+ version: 1,
migrate: (persistedState) => {
if (
typeof persistedState === "object" &&
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index e5723bfa..e4c8ec89 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -5,7 +5,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-import type { ClipboardItem } from "@/types/timeline";
+import type { ClipboardItem } from "@/lib/timeline";
interface TimelineStore {
snappingEnabled: boolean;
diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts
deleted file mode 100644
index 0413b208..00000000
--- a/apps/web/src/types/editor.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type TPlatformLayout = "tiktok";
diff --git a/apps/web/src/types/effects.ts b/apps/web/src/types/effects.ts
deleted file mode 100644
index 6c96dfb2..00000000
--- a/apps/web/src/types/effects.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-export interface Effect {
- id: string;
- type: string;
- params: EffectParamValues;
- enabled: boolean;
-}
-
-export type EffectParamType = "number" | "boolean" | "select" | "color";
-
-export type EffectParamValues = Record<string, number | string | boolean>;
-
-interface BaseEffectParamDefinition {
- key: string;
- label: string;
-}
-
-export interface NumberEffectParamDefinition extends BaseEffectParamDefinition {
- type: "number";
- default: number;
- min: number;
- max: number;
- step: number;
-}
-
-interface BooleanEffectParamDefinition extends BaseEffectParamDefinition {
- type: "boolean";
- default: boolean;
-}
-
-interface SelectEffectParamDefinition extends BaseEffectParamDefinition {
- type: "select";
- default: string;
- options: Array<{ value: string; label: string }>;
-}
-
-interface ColorEffectParamDefinition extends BaseEffectParamDefinition {
- type: "color";
- default: string;
-}
-
-export type EffectParamDefinition =
- | NumberEffectParamDefinition
- | BooleanEffectParamDefinition
- | SelectEffectParamDefinition
- | ColorEffectParamDefinition;
-
-export interface WebGLEffectPass {
- fragmentShader: string;
- uniforms(params: {
- effectParams: EffectParamValues;
- width: number;
- height: number;
- }): Record<string, number | number[]>;
-}
-
-export interface WebGLEffectRenderer {
- type: "webgl";
- passes: WebGLEffectPass[];
-}
-
-export type EffectRenderer = WebGLEffectRenderer;
-
-export interface EffectDefinition {
- type: string;
- name: string;
- keywords: string[];
- params: EffectParamDefinition[];
- renderer: EffectRenderer;
-}
diff --git a/apps/web/src/types/export.ts b/apps/web/src/types/export.ts
deleted file mode 100644
index 2c2408d8..00000000
--- a/apps/web/src/types/export.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-export const EXPORT_QUALITY_VALUES = [
- "low",
- "medium",
- "high",
- "very_high",
-] as const;
-
-export const EXPORT_FORMAT_VALUES = ["mp4", "webm"] as const;
-
-export type ExportFormat = (typeof EXPORT_FORMAT_VALUES)[number];
-export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number];
-
-export interface ExportOptions {
- format: ExportFormat;
- quality: ExportQuality;
- fps?: number;
- includeAudio?: boolean;
-}
-
-export interface ExportResult {
- success: boolean;
- buffer?: ArrayBuffer;
- error?: string;
- cancelled?: boolean;
-}
-
-export interface ExportState {
- isExporting: boolean;
- progress: number;
- result: ExportResult | null;
-}
diff --git a/apps/web/src/types/language.ts b/apps/web/src/types/language.ts
deleted file mode 100644
index a3f527c8..00000000
--- a/apps/web/src/types/language.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import type { LANGUAGES } from "@/constants/language-constants";
-
-export type Language = (typeof LANGUAGES)[number];
-export type LanguageCode = Language["code"];
diff --git a/apps/web/src/types/soundtouchjs.d.ts b/apps/web/src/types/soundtouchjs.d.ts
new file mode 100644
index 00000000..c10f1ff3
--- /dev/null
+++ b/apps/web/src/types/soundtouchjs.d.ts
@@ -0,0 +1,14 @@
+declare module "soundtouchjs" {
+ export class PitchShifter {
+ constructor(
+ context: BaseAudioContext,
+ buffer: AudioBuffer,
+ bufferSize: number,
+ onEnd?: () => void,
+ );
+ tempo: number;
+ pitch: number;
+ connect(destination: AudioNode): void;
+ off(): void;
+ }
+}
diff --git a/apps/web/src/types/stickers.ts b/apps/web/src/types/stickers.ts
deleted file mode 100644
index 65c9aeb2..00000000
--- a/apps/web/src/types/stickers.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { STICKER_CATEGORIES } from "@/constants/sticker-constants";
-
-export type StickerCategory = keyof typeof STICKER_CATEGORIES;
-
-export interface StickerItem {
- id: string;
- provider: string;
- name: string;
- previewUrl: string;
- metadata: Record<string, unknown>;
-}
-
-export interface StickerSearchResult {
- items: StickerItem[];
- total: number;
- hasMore: boolean;
-}
-
-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<StickerSearchResult>;
- resolveUrl({
- stickerId,
- options,
- }: {
- stickerId: string;
- options?: StickerResolveOptions;
- }): string;
-}
diff --git a/apps/web/src/types/time.ts b/apps/web/src/types/time.ts
deleted file mode 100644
index 4009a1e1..00000000
--- a/apps/web/src/types/time.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type TTimeCode = "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF";
diff --git a/apps/web/src/utils/browser.ts b/apps/web/src/utils/browser.ts
index cfd3b31b..bfe69b11 100644
--- a/apps/web/src/utils/browser.ts
+++ b/apps/web/src/utils/browser.ts
@@ -15,6 +15,20 @@ export function downloadBlob({
URL.revokeObjectURL(url);
}
+export function findScrollParent({
+ element,
+}: {
+ element: HTMLElement;
+}): HTMLElement | null {
+ let parent = element.parentElement;
+ while (parent) {
+ const { overflow, overflowX } = window.getComputedStyle(parent);
+ if (/auto|scroll/.test(overflow + overflowX)) return parent;
+ parent = parent.parentElement;
+ }
+ return null;
+}
+
export function isTypableDOMElement({
element,
}: {
diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts
index d45e71c0..24f9eaf4 100644
--- a/apps/web/src/utils/math.ts
+++ b/apps/web/src/utils/math.ts
@@ -10,6 +10,41 @@ export function clamp({
return Math.max(min, Math.min(max, value));
}
+export function clampRound({
+ value,
+ min,
+ max,
+}: {
+ value: number;
+ min: number;
+ max: number;
+}): number {
+ return Math.round(clamp({ value, min, max }));
+}
+
+function getFractionDigitsForStep({ step }: { step: number }): number {
+ const normalizedStep = step.toString().toLowerCase();
+ if (normalizedStep.includes("e-")) {
+ return Number(normalizedStep.split("e-")[1] ?? 0);
+ }
+ const [, fractionalPart = ""] = normalizedStep.split(".");
+ return fractionalPart.length;
+}
+
+export function snapToStep({
+ value,
+ step,
+}: {
+ value: number;
+ step: number;
+}): number {
+ if (step <= 0) return value;
+ const snappedValue = Math.round(value / step) * step;
+ return Number(
+ snappedValue.toFixed(getFractionDigitsForStep({ step })),
+ );
+}
+
export function isNearlyEqual({
leftValue,
rightValue,
@@ -22,6 +57,16 @@ export function isNearlyEqual({
return Math.abs(leftValue - rightValue) <= epsilon;
}
+export function formatNumberForDisplay({
+ value,
+ maxFractionDigits = 6,
+}: {
+ value: number;
+ maxFractionDigits?: number;
+}): string {
+ return Number(value.toFixed(maxFractionDigits)).toString();
+}
+
export function evaluateMathExpression({
input,
}: {