From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- .../src/actions/components/shortcuts-dialog.tsx | 229 +++ apps/web/src/actions/definitions.ts | 209 +++ apps/web/src/actions/index.ts | 3 + apps/web/src/actions/keybinding.ts | 79 + apps/web/src/actions/keybindings-store.ts | 251 +++ .../src/actions/keybindings/migrations/index.ts | 32 + .../src/actions/keybindings/migrations/v2-to-v3.ts | 26 + .../src/actions/keybindings/migrations/v3-to-v4.ts | 25 + .../src/actions/keybindings/migrations/v4-to-v5.ts | 17 + .../src/actions/keybindings/migrations/v5-to-v6.ts | 17 + .../src/actions/keybindings/migrations/v6-to-v7.ts | 19 + apps/web/src/actions/registry.ts | 63 + apps/web/src/actions/types.ts | 44 + apps/web/src/actions/use-action-handler.ts | 52 + apps/web/src/actions/use-editor-actions.ts | 488 ++++++ apps/web/src/actions/use-keybindings.ts | 87 + .../web/src/actions/use-keyboard-shortcuts-help.ts | 83 + .../src/animation/__tests__/binding-values.test.ts | 26 + apps/web/src/animation/bezier.ts | 90 + apps/web/src/animation/binding-values.ts | 344 ++++ apps/web/src/animation/curve-bridge.ts | 95 + apps/web/src/animation/effect-param-channel.ts | 100 ++ apps/web/src/animation/graph-channels.ts | 99 ++ apps/web/src/animation/graphic-param-channel.ts | 73 + apps/web/src/animation/index.ts | 96 ++ apps/web/src/animation/interpolation.ts | 375 ++++ apps/web/src/animation/keyframe-query.ts | 294 ++++ apps/web/src/animation/keyframes.ts | 1338 ++++++++++++++ apps/web/src/animation/property-groups.ts | 39 + apps/web/src/animation/property-registry.ts | 390 +++++ apps/web/src/animation/resolve.ts | 184 ++ apps/web/src/animation/target-resolver.ts | 285 +++ apps/web/src/animation/timeline-snap-points.ts | 35 + apps/web/src/animation/transform.ts | 1 + apps/web/src/animation/types.ts | 222 +++ apps/web/src/app/api/auth/[...all]/route.ts | 2 +- apps/web/src/app/api/feedback/route.ts | 4 +- apps/web/src/app/api/sounds/search/route.ts | 4 +- apps/web/src/app/blog/[slug]/page.tsx | 4 +- apps/web/src/app/blog/page.tsx | 4 +- apps/web/src/app/changelog/[version]/page.tsx | 6 +- apps/web/src/app/changelog/page.tsx | 4 +- apps/web/src/app/contributors/page.tsx | 2 +- apps/web/src/app/editor/[project_id]/page.tsx | 76 +- apps/web/src/app/layout.tsx | 4 +- apps/web/src/app/metadata.ts | 2 +- apps/web/src/app/page.tsx | 2 +- apps/web/src/app/privacy/page.tsx | 2 +- apps/web/src/app/projects/page.tsx | 16 +- apps/web/src/app/projects/store.ts | 2 +- apps/web/src/app/robots.ts | 2 +- apps/web/src/app/rss.xml/route.ts | 4 +- apps/web/src/app/sitemap.ts | 4 +- apps/web/src/app/sponsors/page.tsx | 2 +- apps/web/src/app/terms/page.tsx | 2 +- apps/web/src/auth/client.ts | 6 + apps/web/src/auth/rate-limit.ts | 21 + apps/web/src/auth/server.ts | 43 + apps/web/src/background/blur.ts | 10 + apps/web/src/background/color.ts | 1 + apps/web/src/blog/query.ts | 133 ++ apps/web/src/blog/types.ts | 92 + apps/web/src/canvas/sizes.ts | 10 + .../components/changelog-notification.tsx | 87 + .../changelog/components/copy-markdown-button.tsx | 98 ++ apps/web/src/changelog/components/release.tsx | 153 ++ apps/web/src/changelog/entries/0.1.0.md | 37 + apps/web/src/changelog/entries/0.2.0.md | 47 + apps/web/src/changelog/entries/0.3.0.md | 114 ++ apps/web/src/changelog/entries/0.4.0.md | 19 + apps/web/src/changelog/utils.ts | 82 + apps/web/src/clipboard/handlers/elements.ts | 48 + apps/web/src/clipboard/handlers/index.ts | 49 + apps/web/src/clipboard/handlers/keyframes.ts | 170 ++ apps/web/src/clipboard/index.ts | 2 + apps/web/src/clipboard/types.ts | 79 + apps/web/src/commands/base-command.ts | 31 + apps/web/src/commands/batch-command.ts | 39 + apps/web/src/commands/index.ts | 8 + apps/web/src/commands/media/add-media-asset.ts | 135 ++ apps/web/src/commands/media/index.ts | 2 + apps/web/src/commands/media/remove-media-asset.ts | 111 ++ apps/web/src/commands/preview-tracker.ts | 23 + apps/web/src/commands/project/index.ts | 1 + .../commands/project/update-project-settings.ts | 46 + apps/web/src/commands/scene/create-scene.ts | 41 + apps/web/src/commands/scene/delete-scene.ts | 59 + apps/web/src/commands/scene/index.ts | 7 + apps/web/src/commands/scene/move-bookmark.ts | 59 + apps/web/src/commands/scene/remove-bookmark.ts | 59 + apps/web/src/commands/scene/rename-scene.ts | 46 + apps/web/src/commands/scene/toggle-bookmark.ts | 55 + apps/web/src/commands/scene/update-bookmark.ts | 58 + apps/web/src/commands/timeline/clipboard/index.ts | 2 + .../commands/timeline/clipboard/paste-keyframes.ts | 135 ++ apps/web/src/commands/timeline/clipboard/paste.ts | 193 +++ .../commands/timeline/element/delete-elements.ts | 72 + .../timeline/element/duplicate-elements.ts | 134 ++ .../timeline/element/effects/add-effect.ts | 75 + .../src/commands/timeline/element/effects/index.ts | 5 + .../timeline/element/effects/remove-effect.ts | 66 + .../timeline/element/effects/reorder-effect.ts | 74 + .../timeline/element/effects/toggle-effect.ts | 68 + .../element/effects/update-effect-params.ts | 87 + apps/web/src/commands/timeline/element/index.ts | 11 + .../commands/timeline/element/insert-element.ts | 291 ++++ .../commands/timeline/element/keyframes/index.ts | 6 + .../keyframes/remove-effect-param-keyframe.ts | 69 + .../timeline/element/keyframes/remove-keyframe.ts | 107 ++ .../timeline/element/keyframes/retime-keyframe.ts | 75 + .../keyframes/update-scalar-keyframe-curve.ts | 85 + .../keyframes/upsert-effect-param-keyframe.ts | 104 ++ .../timeline/element/keyframes/upsert-keyframe.ts | 96 ++ .../element/masks/delete-custom-mask-points.ts | 131 ++ .../src/commands/timeline/element/masks/index.ts | 4 + .../element/masks/insert-custom-mask-point.ts | 169 ++ .../commands/timeline/element/masks/remove-mask.ts | 65 + .../timeline/element/masks/toggle-mask-inverted.ts | 76 + .../src/commands/timeline/element/move-elements.ts | 183 ++ .../commands/timeline/element/split-elements.ts | 182 ++ .../element/toggle-source-audio-separation.ts | 145 ++ .../commands/timeline/element/update-elements.ts | 75 + apps/web/src/commands/timeline/index.ts | 5 + apps/web/src/commands/timeline/track/add-track.ts | 115 ++ apps/web/src/commands/timeline/track/index.ts | 4 + .../src/commands/timeline/track/remove-track.ts | 30 + .../commands/timeline/track/toggle-track-mute.ts | 41 + .../timeline/track/toggle-track-visibility.ts | 45 + apps/web/src/commands/timeline/tracks-snapshot.ts | 21 + .../editor/dialogs/delete-project-dialog.tsx | 84 - .../components/editor/dialogs/migration-dialog.tsx | 44 - .../editor/dialogs/project-info-dialog.tsx | 81 - .../editor/dialogs/rename-project-dialog.tsx | 72 - .../components/editor/dialogs/shortcuts-dialog.tsx | 229 --- .../editor/dialogs/storage-persistence-dialog.tsx | 41 - apps/web/src/components/editor/editor-header.tsx | 14 +- apps/web/src/components/editor/export-button.tsx | 8 +- apps/web/src/components/editor/onboarding.tsx | 4 +- .../editor/panels/assets/assets-panel-store.tsx | 124 ++ .../editor/panels/assets/draggable-item.tsx | 6 +- .../src/components/editor/panels/assets/index.tsx | 12 +- .../src/components/editor/panels/assets/tabbar.tsx | 2 +- .../editor/panels/assets/views/assets.tsx | 24 +- .../editor/panels/assets/views/captions.tsx | 337 ---- .../editor/panels/assets/views/effects.tsx | 92 - .../panels/assets/views/settings/background.tsx | 6 +- .../editor/panels/assets/views/settings/index.tsx | 10 +- .../editor/panels/assets/views/sounds.tsx | 563 ------ .../editor/panels/assets/views/stickers.tsx | 455 ----- .../components/editor/panels/assets/views/text.tsx | 46 - .../panels/preview/bookmark-note-overlay.tsx | 59 - .../editor/panels/preview/context-menu.tsx | 73 - .../components/editor/panels/preview/cursors.ts | 21 - .../editor/panels/preview/guide-overlay.tsx | 32 - .../editor/panels/preview/guide-popover.tsx | 100 -- .../editor/panels/preview/handle-primitives.tsx | 427 ----- .../src/components/editor/panels/preview/index.tsx | 294 ---- .../editor/panels/preview/mask-handles.tsx | 308 ---- .../panels/preview/preview-interaction-overlay.tsx | 103 -- .../editor/panels/preview/preview-viewport.tsx | 576 ------- .../editor/panels/preview/snap-guides.tsx | 47 - .../editor/panels/preview/text-edit-overlay.tsx | 158 -- .../components/editor/panels/preview/toolbar.tsx | 148 -- .../editor/panels/preview/transform-handles.tsx | 145 -- .../properties/components/property-param-field.tsx | 2 +- .../properties/hooks/use-element-playhead.ts | 4 +- .../hooks/use-keyframed-color-property.ts | 8 +- .../hooks/use-keyframed-number-property.ts | 8 +- .../hooks/use-keyframed-param-property.ts | 10 +- .../components/editor/panels/properties/index.tsx | 4 +- .../editor/panels/properties/registry.tsx | 20 +- .../editor/panels/properties/tabs/audio-tab.tsx | 151 -- .../editor/panels/properties/tabs/blending-tab.tsx | 230 --- .../editor/panels/properties/tabs/effects-tab.tsx | 309 ---- .../editor/panels/properties/tabs/graphic-tab.tsx | 238 --- .../editor/panels/properties/tabs/masks-tab.tsx | 826 --------- .../editor/panels/properties/tabs/speed-tab.tsx | 156 -- .../editor/panels/properties/tabs/text-tab.tsx | 767 --------- .../panels/properties/tabs/transform-tab.tsx | 462 ----- .../editor/panels/timeline/audio-volume-line.tsx | 275 --- .../editor/panels/timeline/audio-waveform.tsx | 345 ---- .../editor/panels/timeline/bookmarks.tsx | 427 ----- .../editor/panels/timeline/drag-line.tsx | 29 - .../editor/panels/timeline/drop-target.ts | 262 --- .../editor/panels/timeline/expanded-layout.ts | 117 -- .../panels/timeline/graph-editor/bezier-graph.tsx | 235 --- .../timeline/graph-editor/custom-presets-store.ts | 101 -- .../panels/timeline/graph-editor/easing-presets.ts | 19 - .../panels/timeline/graph-editor/popover.tsx | 334 ---- .../editor/panels/timeline/graph-editor/session.ts | 704 -------- .../panels/timeline/graph-editor/use-controller.ts | 178 -- .../components/editor/panels/timeline/index.tsx | 944 ---------- .../editor/panels/timeline/interaction.ts | 4 - .../components/editor/panels/timeline/layers.ts | 6 - .../components/editor/panels/timeline/layout.ts | 20 - .../panels/timeline/selection-hit-testing.ts | 135 -- .../editor/panels/timeline/snap-indicator.tsx | 50 - .../src/components/editor/panels/timeline/theme.ts | 31 - .../editor/panels/timeline/timeline-element.tsx | 1305 -------------- .../editor/panels/timeline/timeline-playhead.tsx | 114 -- .../editor/panels/timeline/timeline-ruler.tsx | 141 -- .../editor/panels/timeline/timeline-tick.tsx | 42 - .../editor/panels/timeline/timeline-toolbar.tsx | 376 ---- .../editor/panels/timeline/timeline-track.tsx | 132 -- .../editor/panels/timeline/track-layout.ts | 60 - apps/web/src/components/editor/scenes-view.tsx | 4 +- apps/web/src/components/footer.tsx | 4 +- .../src/components/gitHub-contribute-section.tsx | 2 +- apps/web/src/components/header.tsx | 4 +- .../src/components/providers/editor-provider.tsx | 12 +- apps/web/src/components/storage-provider.tsx | 2 +- apps/web/src/components/ui/font-picker.tsx | 8 +- .../src/components/ui/use-overlay-open-change.ts | 2 +- apps/web/src/core/index.ts | 6 +- apps/web/src/core/managers/audio-manager.ts | 14 +- apps/web/src/core/managers/clipboard-manager.ts | 2 +- apps/web/src/core/managers/commands.ts | 8 +- apps/web/src/core/managers/diagnostics-manager.ts | 2 +- apps/web/src/core/managers/media-manager.ts | 4 +- apps/web/src/core/managers/playback-manager.ts | 2 +- apps/web/src/core/managers/project-manager.ts | 24 +- apps/web/src/core/managers/renderer-manager.ts | 6 +- apps/web/src/core/managers/scenes-manager.ts | 640 +++---- apps/web/src/core/managers/selection-manager.ts | 6 +- apps/web/src/core/managers/timeline-manager.ts | 24 +- apps/web/src/db/index.ts | 19 + apps/web/src/db/schema.ts | 70 + apps/web/src/diagnostics/types.ts | 8 + apps/web/src/editor/cancel-interaction.ts | 24 + apps/web/src/editor/editor-store.ts | 24 + apps/web/src/editor/panel-store.ts | 93 + apps/web/src/editor/use-editor.ts | 60 + apps/web/src/editor/use-menu-preview.ts | 26 + apps/web/src/effects/components/assets-view.tsx | 92 + apps/web/src/effects/components/effects-tab.tsx | 309 ++++ apps/web/src/effects/definitions/blur.ts | 110 ++ apps/web/src/effects/definitions/index.ts | 13 + apps/web/src/effects/index.ts | 47 + apps/web/src/effects/registry.ts | 10 + apps/web/src/effects/types.ts | 41 + apps/web/src/env/web.ts | 30 + apps/web/src/export/defaults.ts | 7 + apps/web/src/export/index.ts | 70 + apps/web/src/export/mime-types.ts | 4 + .../src/feedback/components/feedback-popover.tsx | 242 +++ apps/web/src/feedback/index.ts | 3 + apps/web/src/feedback/queries.ts | 14 + apps/web/src/feedback/types.ts | 11 + apps/web/src/fonts/google-fonts.ts | 88 + apps/web/src/fonts/system-fonts.ts | 11 + apps/web/src/fonts/types.ts | 24 + apps/web/src/fonts/use-font-atlas.ts | 53 + apps/web/src/fps/__tests__/fps.test.ts | 65 + apps/web/src/fps/defaults.ts | 3 + apps/web/src/fps/presets.ts | 7 + apps/web/src/fps/utils.ts | 95 + apps/web/src/gradients/canvas.ts | 923 ++++++++++ apps/web/src/gradients/index.ts | 2 + apps/web/src/gradients/parser.ts | 652 +++++++ apps/web/src/graphics/components/graphic-tab.tsx | 238 +++ apps/web/src/graphics/definitions/ellipse.ts | 73 + apps/web/src/graphics/definitions/index.ts | 29 + apps/web/src/graphics/definitions/polygon.ts | 211 +++ apps/web/src/graphics/definitions/rectangle.ts | 85 + apps/web/src/graphics/definitions/shared.ts | 16 + apps/web/src/graphics/definitions/star.ts | 109 ++ apps/web/src/graphics/index.ts | 125 ++ apps/web/src/graphics/registry.ts | 10 + apps/web/src/graphics/stroke.ts | 102 ++ apps/web/src/graphics/types.ts | 23 + apps/web/src/guides/definitions/custom.tsx | 24 + apps/web/src/guides/definitions/grid.tsx | 125 ++ apps/web/src/guides/definitions/platforms.tsx | 65 + apps/web/src/guides/definitions/tiktok-layout.tsx | 335 ++++ apps/web/src/guides/grid.ts | 4 + apps/web/src/guides/index.ts | 14 + apps/web/src/guides/preview-overlay.tsx | 33 + apps/web/src/guides/registry.tsx | 28 + apps/web/src/guides/types.ts | 20 + apps/web/src/hooks/actions/use-action-handler.ts | 52 - apps/web/src/hooks/actions/use-editor-actions.ts | 488 ------ apps/web/src/hooks/storage/use-local-storage.ts | 58 - .../timeline/element/use-element-interaction.ts | 760 -------- .../timeline/element/use-element-selection.ts | 137 -- .../timeline/element/use-keyframe-box-select.ts | 222 --- .../hooks/timeline/element/use-keyframe-drag.ts | 314 ---- .../timeline/element/use-keyframe-selection.ts | 229 --- apps/web/src/hooks/timeline/use-bookmark-drag.ts | 278 --- .../web/src/hooks/timeline/use-edge-auto-scroll.ts | 97 -- .../hooks/timeline/use-initial-scroll-bottom.ts | 42 - apps/web/src/hooks/timeline/use-scroll-position.ts | 52 - apps/web/src/hooks/timeline/use-scroll-sync.ts | 173 -- .../hooks/timeline/use-snap-indicator-position.ts | 55 - .../src/hooks/timeline/use-timeline-drag-drop.ts | 648 ------- .../src/hooks/timeline/use-timeline-playhead.ts | 290 ---- apps/web/src/hooks/timeline/use-timeline-resize.ts | 322 ---- apps/web/src/hooks/timeline/use-timeline-seek.ts | 196 --- apps/web/src/hooks/timeline/use-timeline-zoom.ts | 297 ---- apps/web/src/hooks/use-editor.ts | 60 - apps/web/src/hooks/use-element-preview.ts | 39 - apps/web/src/hooks/use-file-upload.ts | 101 -- apps/web/src/hooks/use-font-atlas.ts | 53 - apps/web/src/hooks/use-keybindings.ts | 87 - apps/web/src/hooks/use-keyboard-shortcuts-help.ts | 83 - apps/web/src/hooks/use-mask-handles.ts | 633 ------- apps/web/src/hooks/use-menu-preview.ts | 26 - apps/web/src/hooks/use-paste-media.ts | 116 -- apps/web/src/hooks/use-preview-interaction.ts | 456 ----- apps/web/src/hooks/use-sound-search.ts | 154 -- apps/web/src/hooks/use-storage-persistence.ts | 45 - apps/web/src/hooks/use-transform-handles.ts | 631 ------- apps/web/src/lib/actions/definitions.ts | 209 --- apps/web/src/lib/actions/index.ts | 3 - apps/web/src/lib/actions/keybinding.ts | 79 - apps/web/src/lib/actions/registry.ts | 63 - apps/web/src/lib/actions/types.ts | 44 - .../lib/animation/__tests__/binding-values.test.ts | 26 - apps/web/src/lib/animation/bezier.ts | 90 - apps/web/src/lib/animation/binding-values.ts | 344 ---- apps/web/src/lib/animation/curve-bridge.ts | 95 - apps/web/src/lib/animation/effect-param-channel.ts | 100 -- apps/web/src/lib/animation/graph-channels.ts | 99 -- .../web/src/lib/animation/graphic-param-channel.ts | 73 - apps/web/src/lib/animation/index.ts | 96 -- apps/web/src/lib/animation/interpolation.ts | 375 ---- apps/web/src/lib/animation/keyframe-query.ts | 294 ---- apps/web/src/lib/animation/keyframes.ts | 1338 -------------- apps/web/src/lib/animation/property-groups.ts | 39 - apps/web/src/lib/animation/property-registry.ts | 390 ----- apps/web/src/lib/animation/resolve.ts | 184 -- apps/web/src/lib/animation/target-resolver.ts | 285 --- apps/web/src/lib/animation/transform.ts | 1 - apps/web/src/lib/animation/types.ts | 222 --- apps/web/src/lib/auth/client.ts | 6 - apps/web/src/lib/auth/server.ts | 43 - apps/web/src/lib/background/blur.ts | 10 - apps/web/src/lib/background/color.ts | 1 - apps/web/src/lib/blog/query.ts | 133 -- apps/web/src/lib/blog/types.ts | 92 - apps/web/src/lib/cancel-interaction.ts | 24 - apps/web/src/lib/canvas/sizes.ts | 10 - .../components/changelog-notification.tsx | 87 - .../changelog/components/copy-markdown-button.tsx | 98 -- apps/web/src/lib/changelog/components/release.tsx | 153 -- apps/web/src/lib/changelog/entries/0.1.0.md | 37 - apps/web/src/lib/changelog/entries/0.2.0.md | 47 - apps/web/src/lib/changelog/entries/0.3.0.md | 114 -- apps/web/src/lib/changelog/entries/0.4.0.md | 19 - apps/web/src/lib/changelog/utils.ts | 82 - apps/web/src/lib/clipboard/handlers/elements.ts | 48 - apps/web/src/lib/clipboard/handlers/index.ts | 49 - apps/web/src/lib/clipboard/handlers/keyframes.ts | 170 -- apps/web/src/lib/clipboard/index.ts | 2 - apps/web/src/lib/clipboard/types.ts | 79 - apps/web/src/lib/commands/base-command.ts | 31 - apps/web/src/lib/commands/batch-command.ts | 39 - apps/web/src/lib/commands/index.ts | 8 - apps/web/src/lib/commands/media/add-media-asset.ts | 135 -- apps/web/src/lib/commands/media/index.ts | 2 - .../src/lib/commands/media/remove-media-asset.ts | 111 -- apps/web/src/lib/commands/preview-tracker.ts | 23 - apps/web/src/lib/commands/project/index.ts | 1 - .../commands/project/update-project-settings.ts | 46 - apps/web/src/lib/commands/scene/create-scene.ts | 41 - apps/web/src/lib/commands/scene/delete-scene.ts | 59 - apps/web/src/lib/commands/scene/index.ts | 7 - apps/web/src/lib/commands/scene/move-bookmark.ts | 59 - apps/web/src/lib/commands/scene/remove-bookmark.ts | 59 - apps/web/src/lib/commands/scene/rename-scene.ts | 46 - apps/web/src/lib/commands/scene/toggle-bookmark.ts | 52 - apps/web/src/lib/commands/scene/update-bookmark.ts | 55 - .../src/lib/commands/timeline/clipboard/index.ts | 2 - .../commands/timeline/clipboard/paste-keyframes.ts | 135 -- .../commands/timeline/element/delete-elements.ts | 72 - .../timeline/element/effects/add-effect.ts | 75 - .../lib/commands/timeline/element/effects/index.ts | 5 - .../timeline/element/effects/remove-effect.ts | 66 - .../timeline/element/effects/reorder-effect.ts | 74 - .../timeline/element/effects/toggle-effect.ts | 68 - .../element/effects/update-effect-params.ts | 87 - .../web/src/lib/commands/timeline/element/index.ts | 11 - .../commands/timeline/element/insert-element.ts | 291 ---- .../commands/timeline/element/keyframes/index.ts | 6 - .../keyframes/remove-effect-param-keyframe.ts | 69 - .../timeline/element/keyframes/remove-keyframe.ts | 107 -- .../timeline/element/keyframes/retime-keyframe.ts | 75 - .../keyframes/update-scalar-keyframe-curve.ts | 85 - .../keyframes/upsert-effect-param-keyframe.ts | 104 -- .../timeline/element/keyframes/upsert-keyframe.ts | 96 -- .../element/masks/delete-custom-mask-points.ts | 131 -- .../lib/commands/timeline/element/masks/index.ts | 4 - .../element/masks/insert-custom-mask-point.ts | 169 -- .../commands/timeline/element/masks/remove-mask.ts | 65 - .../timeline/element/masks/toggle-mask-inverted.ts | 76 - .../element/toggle-source-audio-separation.ts | 145 -- .../commands/timeline/element/update-elements.ts | 75 - apps/web/src/lib/commands/timeline/index.ts | 5 - .../src/lib/commands/timeline/track/add-track.ts | 115 -- apps/web/src/lib/commands/timeline/track/index.ts | 4 - .../lib/commands/timeline/track/remove-track.ts | 30 - .../commands/timeline/track/toggle-track-mute.ts | 41 - .../timeline/track/toggle-track-visibility.ts | 45 - .../src/lib/commands/timeline/tracks-snapshot.ts | 21 - apps/web/src/lib/db/index.ts | 19 - apps/web/src/lib/db/schema.ts | 70 - apps/web/src/lib/diagnostics/types.ts | 8 - apps/web/src/lib/drag-data.ts | 48 - apps/web/src/lib/effects/definitions/blur.ts | 110 -- apps/web/src/lib/effects/definitions/index.ts | 13 - apps/web/src/lib/effects/index.ts | 47 - apps/web/src/lib/effects/registry.ts | 10 - apps/web/src/lib/effects/types.ts | 41 - apps/web/src/lib/env/web.ts | 30 - apps/web/src/lib/export/defaults.ts | 7 - apps/web/src/lib/export/index.ts | 70 - apps/web/src/lib/export/mime-types.ts | 4 - .../lib/feedback/components/feedback-popover.tsx | 242 --- apps/web/src/lib/feedback/index.ts | 3 - apps/web/src/lib/feedback/queries.ts | 14 - apps/web/src/lib/feedback/types.ts | 11 - apps/web/src/lib/fonts/google-fonts.ts | 88 - apps/web/src/lib/fonts/system-fonts.ts | 11 - apps/web/src/lib/fonts/types.ts | 24 - apps/web/src/lib/fps/__tests__/fps.test.ts | 65 - apps/web/src/lib/fps/defaults.ts | 3 - apps/web/src/lib/fps/presets.ts | 7 - apps/web/src/lib/fps/utils.ts | 95 - apps/web/src/lib/gradients/canvas.ts | 923 ---------- apps/web/src/lib/gradients/index.ts | 2 - apps/web/src/lib/gradients/parser.ts | 652 ------- apps/web/src/lib/graphics/definitions/ellipse.ts | 73 - apps/web/src/lib/graphics/definitions/index.ts | 29 - apps/web/src/lib/graphics/definitions/polygon.ts | 211 --- apps/web/src/lib/graphics/definitions/rectangle.ts | 85 - apps/web/src/lib/graphics/definitions/shared.ts | 16 - apps/web/src/lib/graphics/definitions/star.ts | 109 -- apps/web/src/lib/graphics/index.ts | 125 -- apps/web/src/lib/graphics/registry.ts | 10 - apps/web/src/lib/graphics/stroke.ts | 102 -- apps/web/src/lib/graphics/types.ts | 23 - apps/web/src/lib/guides/definitions/custom.tsx | 24 - apps/web/src/lib/guides/definitions/grid.tsx | 125 -- apps/web/src/lib/guides/definitions/platforms.tsx | 65 - .../src/lib/guides/definitions/tiktok-layout.tsx | 335 ---- apps/web/src/lib/guides/grid.ts | 4 - apps/web/src/lib/guides/index.ts | 13 - apps/web/src/lib/guides/registry.tsx | 28 - apps/web/src/lib/guides/types.ts | 20 - apps/web/src/lib/masks/__tests__/snap.test.ts | 487 ------ apps/web/src/lib/masks/custom-path.ts | 842 --------- apps/web/src/lib/masks/definitions/box-like.ts | 313 ---- .../src/lib/masks/definitions/cinematic-bars.ts | 135 -- apps/web/src/lib/masks/definitions/custom.ts | 652 ------- apps/web/src/lib/masks/definitions/diamond.ts | 100 -- apps/web/src/lib/masks/definitions/ellipse.ts | 75 - apps/web/src/lib/masks/definitions/heart.ts | 142 -- apps/web/src/lib/masks/definitions/index.ts | 74 - apps/web/src/lib/masks/definitions/rectangle.ts | 97 -- apps/web/src/lib/masks/definitions/split.ts | 382 ---- apps/web/src/lib/masks/definitions/star.ts | 140 -- apps/web/src/lib/masks/definitions/text.ts | 438 ----- apps/web/src/lib/masks/dimensions.ts | 2 - apps/web/src/lib/masks/feather.ts | 2 - apps/web/src/lib/masks/geometry.ts | 121 -- apps/web/src/lib/masks/handle-positions.ts | 497 ------ apps/web/src/lib/masks/index.ts | 18 - apps/web/src/lib/masks/param-update.ts | 26 - apps/web/src/lib/masks/registry.ts | 122 -- apps/web/src/lib/masks/snap.ts | 336 ---- apps/web/src/lib/masks/types.ts | 298 ---- apps/web/src/lib/masks/utils.ts | 64 - apps/web/src/lib/media/audio-mastering.ts | 103 -- apps/web/src/lib/media/audio.ts | 868 ---------- apps/web/src/lib/media/media-utils.ts | 32 - apps/web/src/lib/media/mediabunny.ts | 168 -- apps/web/src/lib/media/processing.ts | 296 ---- apps/web/src/lib/media/types.ts | 9 - apps/web/src/lib/media/upload-toast.ts | 51 - apps/web/src/lib/media/waveform-summary.ts | 233 --- apps/web/src/lib/panels/layout.ts | 9 - apps/web/src/lib/params.ts | 50 - apps/web/src/lib/preview/element-bounds.ts | 308 ---- apps/web/src/lib/preview/hit-test.ts | 102 -- apps/web/src/lib/preview/preview-coords.ts | 101 -- apps/web/src/lib/preview/preview-snap.ts | 539 ------ apps/web/src/lib/preview/zoom.ts | 7 - apps/web/src/lib/project/types.ts | 54 - apps/web/src/lib/rate-limit.ts | 21 - apps/web/src/lib/registry.ts | 40 - apps/web/src/lib/rendering.ts | 28 - apps/web/src/lib/retime/__tests__/resolve.test.ts | 61 - apps/web/src/lib/retime/__tests__/split.test.ts | 31 - apps/web/src/lib/retime/audio-stretch.ts | 182 -- apps/web/src/lib/retime/index.ts | 5 - apps/web/src/lib/retime/presets.ts | 12 - apps/web/src/lib/retime/rate.ts | 25 - apps/web/src/lib/retime/resolve.ts | 48 - apps/web/src/lib/retime/split.ts | 34 - apps/web/src/lib/ripple/apply.ts | 77 - apps/web/src/lib/ripple/diff.ts | 279 --- apps/web/src/lib/ripple/index.ts | 4 - apps/web/src/lib/ripple/shift.ts | 17 - apps/web/src/lib/scenes.ts | 113 -- apps/web/src/lib/selection/attributes.ts | 1 - apps/web/src/lib/selection/context.tsx | 52 - apps/web/src/lib/selection/editor-selection.ts | 25 - apps/web/src/lib/selection/hit-testing.ts | 50 - apps/web/src/lib/selection/hooks/use-box-select.ts | 170 -- .../src/lib/selection/hooks/use-selection-scope.ts | 28 - apps/web/src/lib/selection/index.ts | 4 - apps/web/src/lib/selection/scope.ts | 30 - apps/web/src/lib/selection/selectable-item.tsx | 107 -- apps/web/src/lib/selection/selectable-surface.tsx | 288 ---- apps/web/src/lib/selection/selection-box.tsx | 50 - apps/web/src/lib/selection/state.ts | 165 -- apps/web/src/lib/selection/types.ts | 39 - apps/web/src/lib/site/brand.ts | 13 - apps/web/src/lib/site/external-tools.ts | 24 - apps/web/src/lib/site/social.ts | 5 - apps/web/src/lib/site/sponsors.ts | 24 - apps/web/src/lib/sounds/types.ts | 39 - .../src/lib/stickers/__tests__/sticker-id.test.ts | 34 - apps/web/src/lib/stickers/categories.ts | 7 - apps/web/src/lib/stickers/index.ts | 317 ---- apps/web/src/lib/stickers/intrinsic-size.ts | 1 - .../src/lib/stickers/providers/countries-data.ts | 1819 -------------------- apps/web/src/lib/stickers/providers/flags.ts | 202 --- apps/web/src/lib/stickers/providers/index.ts | 24 - apps/web/src/lib/stickers/providers/logos.ts | 31 - apps/web/src/lib/stickers/providers/shapes.ts | 206 --- apps/web/src/lib/stickers/registry.ts | 10 - apps/web/src/lib/stickers/resolver.ts | 20 - apps/web/src/lib/stickers/sticker-id.ts | 34 - apps/web/src/lib/stickers/types.ts | 71 - apps/web/src/lib/subtitles/ass.ts | 514 ------ .../lib/subtitles/build-subtitle-text-element.ts | 333 ---- apps/web/src/lib/subtitles/insert.ts | 40 - apps/web/src/lib/subtitles/parse.ts | 28 - apps/web/src/lib/subtitles/srt.ts | 98 -- apps/web/src/lib/subtitles/types.ts | 46 - apps/web/src/lib/text/background.ts | 2 - apps/web/src/lib/text/layout.ts | 226 --- apps/web/src/lib/text/measure-element.ts | 133 -- apps/web/src/lib/text/primitives.ts | 241 --- apps/web/src/lib/text/typography.ts | 9 - apps/web/src/lib/timeline/audio-constants.ts | 2 - apps/web/src/lib/timeline/audio-display.ts | 64 - .../web/src/lib/timeline/audio-separation/index.ts | 143 -- apps/web/src/lib/timeline/audio-state.ts | 116 -- apps/web/src/lib/timeline/bookmarks.ts | 145 -- apps/web/src/lib/timeline/creation.ts | 3 - apps/web/src/lib/timeline/defaults.ts | 77 - apps/web/src/lib/timeline/drag-utils.ts | 21 - apps/web/src/lib/timeline/drag.ts | 42 - apps/web/src/lib/timeline/element-utils.ts | 426 ----- .../web/src/lib/timeline/group-move/build-group.ts | 87 - apps/web/src/lib/timeline/group-move/index.ts | 12 - .../src/lib/timeline/group-move/resolve-move.ts | 470 ----- apps/web/src/lib/timeline/group-move/snap.ts | 71 - .../src/lib/timeline/group-move/track-placement.ts | 83 - apps/web/src/lib/timeline/group-move/types.ts | 36 - .../lib/timeline/group-resize/compute-resize.ts | 249 --- apps/web/src/lib/timeline/group-resize/index.ts | 9 - apps/web/src/lib/timeline/group-resize/types.ts | 36 - apps/web/src/lib/timeline/index.ts | 29 - apps/web/src/lib/timeline/pixel-utils.ts | 80 - .../timeline/placement/__tests__/resolve.test.ts | 661 ------- apps/web/src/lib/timeline/placement/apply.ts | 158 -- .../src/lib/timeline/placement/compatibility.ts | 51 - apps/web/src/lib/timeline/placement/index.ts | 12 - .../web/src/lib/timeline/placement/insert-index.ts | 88 - apps/web/src/lib/timeline/placement/main-track.ts | 54 - apps/web/src/lib/timeline/placement/overlap.ts | 44 - apps/web/src/lib/timeline/placement/resolve.ts | 277 --- .../src/lib/timeline/placement/track-factory.ts | 123 -- apps/web/src/lib/timeline/placement/types.ts | 39 - apps/web/src/lib/timeline/ruler-utils.ts | 257 --- apps/web/src/lib/timeline/scale.ts | 3 - apps/web/src/lib/timeline/snap-utils.ts | 182 -- apps/web/src/lib/timeline/track-capabilities.ts | 20 - apps/web/src/lib/timeline/track-element-update.ts | 147 -- apps/web/src/lib/timeline/tracks.ts | 9 - apps/web/src/lib/timeline/types.ts | 314 ---- apps/web/src/lib/timeline/update-pipeline.ts | 211 --- apps/web/src/lib/timeline/zoom-utils.ts | 90 - apps/web/src/lib/transcription/audio.ts | 4 - apps/web/src/lib/transcription/caption-defaults.ts | 2 - apps/web/src/lib/transcription/caption.ts | 49 - apps/web/src/lib/transcription/diagnostics.ts | 25 - apps/web/src/lib/transcription/languages.ts | 14 - apps/web/src/lib/transcription/models.ts | 34 - .../src/lib/transcription/supported-languages.ts | 1 - apps/web/src/lib/transcription/types.ts | 47 - apps/web/src/lib/wasm/index.ts | 1 - apps/web/src/lib/wasm/ticks.ts | 3 - apps/web/src/masks/__tests__/snap.test.ts | 487 ++++++ apps/web/src/masks/components/masks-tab.tsx | 823 +++++++++ apps/web/src/masks/custom-path.ts | 842 +++++++++ apps/web/src/masks/definitions/box-like.ts | 313 ++++ apps/web/src/masks/definitions/cinematic-bars.ts | 135 ++ apps/web/src/masks/definitions/custom.ts | 652 +++++++ apps/web/src/masks/definitions/diamond.ts | 100 ++ apps/web/src/masks/definitions/ellipse.ts | 75 + apps/web/src/masks/definitions/heart.ts | 142 ++ apps/web/src/masks/definitions/index.ts | 74 + apps/web/src/masks/definitions/rectangle.ts | 97 ++ apps/web/src/masks/definitions/split.ts | 382 ++++ apps/web/src/masks/definitions/star.ts | 140 ++ apps/web/src/masks/definitions/text.ts | 438 +++++ apps/web/src/masks/dimensions.ts | 2 + apps/web/src/masks/feather.ts | 2 + apps/web/src/masks/geometry.ts | 121 ++ apps/web/src/masks/handle-positions.ts | 497 ++++++ apps/web/src/masks/index.ts | 18 + apps/web/src/masks/param-update.ts | 26 + apps/web/src/masks/registry.ts | 122 ++ apps/web/src/masks/snap.ts | 336 ++++ apps/web/src/masks/types.ts | 298 ++++ apps/web/src/masks/use-mask-handles.ts | 633 +++++++ apps/web/src/masks/utils.ts | 64 + apps/web/src/media/audio-mastering.ts | 103 ++ apps/web/src/media/audio.ts | 868 ++++++++++ apps/web/src/media/media-utils.ts | 32 + apps/web/src/media/mediabunny.ts | 168 ++ apps/web/src/media/processing.ts | 296 ++++ apps/web/src/media/types.ts | 9 + apps/web/src/media/upload-toast.ts | 51 + apps/web/src/media/use-file-upload.ts | 101 ++ apps/web/src/media/use-paste-media.ts | 116 ++ apps/web/src/media/waveform-summary.ts | 233 +++ apps/web/src/panels/layout.ts | 9 + apps/web/src/params/index.ts | 50 + apps/web/src/params/registry.ts | 40 + apps/web/src/preview/components/context-menu.tsx | 85 + apps/web/src/preview/components/cursors.ts | 21 + apps/web/src/preview/components/guide-popover.tsx | 100 ++ .../src/preview/components/handle-primitives.tsx | 427 +++++ apps/web/src/preview/components/index.tsx | 328 ++++ apps/web/src/preview/components/mask-handles.tsx | 308 ++++ apps/web/src/preview/components/overlay-layer.tsx | 197 +++ .../components/preview-interaction-overlay.tsx | 103 ++ .../src/preview/components/preview-viewport.tsx | 576 +++++++ apps/web/src/preview/components/snap-guides.tsx | 47 + .../src/preview/components/text-edit-overlay.tsx | 158 ++ apps/web/src/preview/components/toolbar.tsx | 148 ++ .../src/preview/components/transform-handles.tsx | 145 ++ apps/web/src/preview/element-bounds.ts | 308 ++++ apps/web/src/preview/hit-test.ts | 102 ++ .../src/preview/hooks/use-preview-interaction.ts | 456 +++++ .../web/src/preview/hooks/use-transform-handles.ts | 629 +++++++ apps/web/src/preview/overlays.ts | 108 ++ apps/web/src/preview/preview-coords.ts | 101 ++ apps/web/src/preview/preview-snap.ts | 539 ++++++ apps/web/src/preview/preview-store.ts | 104 ++ apps/web/src/preview/zoom.ts | 7 + .../project/components/delete-project-dialog.tsx | 84 + .../src/project/components/migration-dialog.tsx | 44 + .../src/project/components/project-info-dialog.tsx | 81 + .../project/components/rename-project-dialog.tsx | 72 + apps/web/src/project/types.ts | 54 + apps/web/src/rendering/components/blending-tab.tsx | 230 +++ .../web/src/rendering/components/transform-tab.tsx | 462 +++++ apps/web/src/rendering/index.ts | 28 + apps/web/src/retime/__tests__/resolve.test.ts | 61 + apps/web/src/retime/__tests__/split.test.ts | 31 + apps/web/src/retime/audio-stretch.ts | 182 ++ apps/web/src/retime/index.ts | 5 + apps/web/src/retime/presets.ts | 12 + apps/web/src/retime/rate.ts | 25 + apps/web/src/retime/resolve.ts | 48 + apps/web/src/retime/split.ts | 34 + apps/web/src/ripple/apply.ts | 77 + apps/web/src/ripple/diff.ts | 279 +++ apps/web/src/ripple/index.ts | 4 + apps/web/src/ripple/shift.ts | 17 + apps/web/src/selection/attributes.ts | 1 + apps/web/src/selection/context.tsx | 52 + apps/web/src/selection/editor-selection.ts | 25 + apps/web/src/selection/hit-testing.ts | 50 + apps/web/src/selection/hooks/use-box-select.ts | 170 ++ .../web/src/selection/hooks/use-selection-scope.ts | 28 + apps/web/src/selection/index.ts | 4 + apps/web/src/selection/scope.ts | 30 + apps/web/src/selection/selectable-item.tsx | 107 ++ apps/web/src/selection/selectable-surface.tsx | 288 ++++ apps/web/src/selection/selection-box.tsx | 50 + apps/web/src/selection/state.ts | 165 ++ apps/web/src/selection/types.ts | 39 + .../renderer/compositor/frame-descriptor.ts | 6 +- apps/web/src/services/renderer/compositor/types.ts | 4 +- apps/web/src/services/renderer/effect-preview.ts | 6 +- apps/web/src/services/renderer/gpu-renderer.ts | 2 +- .../renderer/nodes/blur-background-node.ts | 4 +- .../services/renderer/nodes/effect-layer-node.ts | 4 +- .../src/services/renderer/nodes/graphic-node.ts | 4 +- .../src/services/renderer/nodes/sticker-node.ts | 2 +- apps/web/src/services/renderer/nodes/text-node.ts | 10 +- .../web/src/services/renderer/nodes/visual-node.ts | 8 +- apps/web/src/services/renderer/resolve.ts | 16 +- apps/web/src/services/renderer/scene-builder.ts | 8 +- apps/web/src/services/renderer/scene-exporter.ts | 6 +- .../components/storage-persistence-dialog.tsx | 41 + .../storage/migrations/__tests__/v1-to-v2.test.ts | 6 +- .../storage/migrations/transformers/v1-to-v2.ts | 6 +- .../storage/migrations/transformers/v14-to-v15.ts | 2 +- .../storage/migrations/transformers/v17-to-v18.ts | 4 +- .../storage/migrations/transformers/v20-to-v21.ts | 2 +- .../storage/migrations/transformers/v22-to-v23.ts | 2 +- .../storage/migrations/transformers/v26-to-v27.ts | 2 +- apps/web/src/services/storage/service.ts | 10 +- apps/web/src/services/storage/types.ts | 6 +- apps/web/src/services/storage/use-local-storage.ts | 58 + .../services/storage/use-storage-persistence.ts | 45 + apps/web/src/services/transcription/service.ts | 4 +- apps/web/src/services/transcription/worker.ts | 4 +- apps/web/src/services/waveform-cache/service.ts | 4 +- apps/web/src/site/brand.ts | 13 + apps/web/src/site/external-tools.ts | 24 + apps/web/src/site/social.ts | 5 + apps/web/src/site/sponsors.ts | 24 + apps/web/src/sounds/components/assets-view.tsx | 563 ++++++ apps/web/src/sounds/sounds-store.ts | 251 +++ apps/web/src/sounds/types.ts | 39 + apps/web/src/sounds/use-sound-search.ts | 154 ++ apps/web/src/speed/components/speed-tab.tsx | 156 ++ apps/web/src/stickers/__tests__/sticker-id.test.ts | 34 + apps/web/src/stickers/categories.ts | 7 + apps/web/src/stickers/components/assets-view.tsx | 455 +++++ apps/web/src/stickers/index.ts | 317 ++++ apps/web/src/stickers/intrinsic-size.ts | 1 + apps/web/src/stickers/providers/countries-data.ts | 1819 ++++++++++++++++++++ apps/web/src/stickers/providers/flags.ts | 202 +++ apps/web/src/stickers/providers/index.ts | 24 + apps/web/src/stickers/providers/logos.ts | 31 + apps/web/src/stickers/providers/shapes.ts | 206 +++ apps/web/src/stickers/registry.ts | 10 + apps/web/src/stickers/resolver.ts | 20 + apps/web/src/stickers/sticker-id.ts | 34 + apps/web/src/stickers/stickers-store.ts | 238 +++ apps/web/src/stickers/types.ts | 71 + apps/web/src/stores/assets-panel-store.tsx | 124 -- apps/web/src/stores/editor-store.ts | 24 - apps/web/src/stores/keybindings-store.ts | 251 --- .../web/src/stores/keybindings/migrations/index.ts | 32 - .../src/stores/keybindings/migrations/v2-to-v3.ts | 26 - .../src/stores/keybindings/migrations/v3-to-v4.ts | 25 - .../src/stores/keybindings/migrations/v4-to-v5.ts | 17 - .../src/stores/keybindings/migrations/v5-to-v6.ts | 17 - .../src/stores/keybindings/migrations/v6-to-v7.ts | 19 - apps/web/src/stores/panel-store.ts | 93 - apps/web/src/stores/preview-store.ts | 112 -- apps/web/src/stores/properties-store.ts | 19 - apps/web/src/stores/sounds-store.ts | 251 --- apps/web/src/stores/stickers-store.ts | 238 --- apps/web/src/stores/timeline-store.ts | 57 - apps/web/src/subtitles/ass.ts | 514 ++++++ .../src/subtitles/build-subtitle-text-element.ts | 333 ++++ apps/web/src/subtitles/components/assets-view.tsx | 337 ++++ apps/web/src/subtitles/insert.ts | 40 + apps/web/src/subtitles/parse.ts | 28 + apps/web/src/subtitles/srt.ts | 98 ++ apps/web/src/subtitles/types.ts | 46 + apps/web/src/text/background.ts | 2 + apps/web/src/text/components/assets-view.tsx | 46 + apps/web/src/text/components/text-tab.tsx | 764 ++++++++ apps/web/src/text/layout.ts | 226 +++ apps/web/src/text/measure-element.ts | 133 ++ apps/web/src/text/primitives.ts | 241 +++ apps/web/src/text/typography.ts | 9 + apps/web/src/timeline/audio-constants.ts | 2 + apps/web/src/timeline/audio-display.ts | 64 + apps/web/src/timeline/audio-separation/index.ts | 143 ++ apps/web/src/timeline/audio-state.ts | 116 ++ .../timeline/bookmarks/components/bookmarks.tsx | 425 +++++ .../timeline/bookmarks/hooks/use-bookmark-drag.ts | 298 ++++ apps/web/src/timeline/bookmarks/index.ts | 19 + .../timeline/bookmarks/preview-overlay-source.tsx | 85 + apps/web/src/timeline/bookmarks/snap-source.ts | 20 + apps/web/src/timeline/bookmarks/utils.ts | 145 ++ apps/web/src/timeline/components/audio-tab.tsx | 151 ++ .../src/timeline/components/audio-volume-line.tsx | 275 +++ .../web/src/timeline/components/audio-waveform.tsx | 345 ++++ apps/web/src/timeline/components/drag-line.tsx | 29 + apps/web/src/timeline/components/drop-target.ts | 262 +++ .../web/src/timeline/components/expanded-layout.ts | 117 ++ .../components/graph-editor/bezier-graph.tsx | 235 +++ .../graph-editor/custom-presets-store.ts | 101 ++ .../components/graph-editor/easing-presets.ts | 19 + .../timeline/components/graph-editor/popover.tsx | 334 ++++ .../timeline/components/graph-editor/session.ts | 704 ++++++++ .../components/graph-editor/use-controller.ts | 178 ++ apps/web/src/timeline/components/index.tsx | 946 ++++++++++ apps/web/src/timeline/components/interaction.ts | 4 + apps/web/src/timeline/components/layers.ts | 6 + apps/web/src/timeline/components/layout.ts | 20 + .../timeline/components/selection-hit-testing.ts | 135 ++ .../web/src/timeline/components/snap-indicator.tsx | 50 + apps/web/src/timeline/components/theme.ts | 31 + .../src/timeline/components/timeline-element.tsx | 1302 ++++++++++++++ .../src/timeline/components/timeline-playhead.tsx | 117 ++ .../web/src/timeline/components/timeline-ruler.tsx | 146 ++ apps/web/src/timeline/components/timeline-tick.tsx | 42 + .../src/timeline/components/timeline-toolbar.tsx | 376 ++++ .../web/src/timeline/components/timeline-track.tsx | 132 ++ apps/web/src/timeline/components/track-layout.ts | 60 + apps/web/src/timeline/creation.ts | 3 + apps/web/src/timeline/defaults.ts | 77 + apps/web/src/timeline/drag-data.ts | 48 + apps/web/src/timeline/drag-utils.ts | 21 + apps/web/src/timeline/drag.ts | 42 + apps/web/src/timeline/element-snap-source.ts | 38 + apps/web/src/timeline/element-utils.ts | 426 +++++ apps/web/src/timeline/group-move/build-group.ts | 87 + apps/web/src/timeline/group-move/index.ts | 12 + apps/web/src/timeline/group-move/resolve-move.ts | 470 +++++ apps/web/src/timeline/group-move/snap.ts | 85 + .../web/src/timeline/group-move/track-placement.ts | 83 + apps/web/src/timeline/group-move/types.ts | 36 + .../src/timeline/group-resize/compute-resize.ts | 249 +++ apps/web/src/timeline/group-resize/index.ts | 9 + apps/web/src/timeline/group-resize/types.ts | 36 + .../hooks/element/use-element-interaction.ts | 760 ++++++++ .../hooks/element/use-element-selection.ts | 137 ++ .../hooks/element/use-keyframe-box-select.ts | 217 +++ .../timeline/hooks/element/use-keyframe-drag.ts | 328 ++++ .../hooks/element/use-keyframe-selection.ts | 229 +++ .../web/src/timeline/hooks/use-edge-auto-scroll.ts | 97 ++ apps/web/src/timeline/hooks/use-element-preview.ts | 39 + .../timeline/hooks/use-initial-scroll-bottom.ts | 42 + apps/web/src/timeline/hooks/use-scroll-position.ts | 52 + apps/web/src/timeline/hooks/use-scroll-sync.ts | 173 ++ .../timeline/hooks/use-snap-indicator-position.ts | 55 + .../src/timeline/hooks/use-timeline-drag-drop.ts | 648 +++++++ .../src/timeline/hooks/use-timeline-playhead.ts | 301 ++++ apps/web/src/timeline/hooks/use-timeline-resize.ts | 332 ++++ apps/web/src/timeline/hooks/use-timeline-seek.ts | 197 +++ apps/web/src/timeline/hooks/use-timeline-zoom.ts | 291 ++++ apps/web/src/timeline/index.ts | 29 + apps/web/src/timeline/pixel-utils.ts | 80 + .../timeline/placement/__tests__/resolve.test.ts | 661 +++++++ apps/web/src/timeline/placement/apply.ts | 158 ++ apps/web/src/timeline/placement/compatibility.ts | 51 + apps/web/src/timeline/placement/index.ts | 12 + apps/web/src/timeline/placement/insert-index.ts | 88 + apps/web/src/timeline/placement/main-track.ts | 54 + apps/web/src/timeline/placement/overlap.ts | 44 + apps/web/src/timeline/placement/resolve.ts | 277 +++ apps/web/src/timeline/placement/track-factory.ts | 123 ++ apps/web/src/timeline/placement/types.ts | 39 + apps/web/src/timeline/playhead-snap-source.ts | 9 + apps/web/src/timeline/ruler-utils.ts | 257 +++ apps/web/src/timeline/scale.ts | 3 + apps/web/src/timeline/scenes.ts | 113 ++ apps/web/src/timeline/snapping/build.ts | 17 + apps/web/src/timeline/snapping/index.ts | 9 + apps/web/src/timeline/snapping/resolve.ts | 28 + apps/web/src/timeline/snapping/threshold.ts | 15 + apps/web/src/timeline/snapping/types.ts | 21 + apps/web/src/timeline/timeline-store.ts | 57 + apps/web/src/timeline/track-capabilities.ts | 20 + apps/web/src/timeline/track-element-update.ts | 147 ++ apps/web/src/timeline/tracks.ts | 9 + apps/web/src/timeline/types.ts | 314 ++++ apps/web/src/timeline/update-pipeline.ts | 211 +++ apps/web/src/timeline/zoom-utils.ts | 90 + apps/web/src/transcription/audio.ts | 4 + apps/web/src/transcription/caption-defaults.ts | 2 + apps/web/src/transcription/caption.ts | 49 + apps/web/src/transcription/diagnostics.ts | 25 + apps/web/src/transcription/languages.ts | 14 + apps/web/src/transcription/models.ts | 34 + apps/web/src/transcription/supported-languages.ts | 1 + apps/web/src/transcription/types.ts | 47 + apps/web/src/wasm/index.ts | 1 + apps/web/src/wasm/ticks.ts | 3 + 875 files changed, 57168 insertions(+), 55975 deletions(-) create mode 100644 apps/web/src/actions/components/shortcuts-dialog.tsx create mode 100644 apps/web/src/actions/definitions.ts create mode 100644 apps/web/src/actions/index.ts create mode 100644 apps/web/src/actions/keybinding.ts create mode 100644 apps/web/src/actions/keybindings-store.ts create mode 100644 apps/web/src/actions/keybindings/migrations/index.ts create mode 100644 apps/web/src/actions/keybindings/migrations/v2-to-v3.ts create mode 100644 apps/web/src/actions/keybindings/migrations/v3-to-v4.ts create mode 100644 apps/web/src/actions/keybindings/migrations/v4-to-v5.ts create mode 100644 apps/web/src/actions/keybindings/migrations/v5-to-v6.ts create mode 100644 apps/web/src/actions/keybindings/migrations/v6-to-v7.ts create mode 100644 apps/web/src/actions/registry.ts create mode 100644 apps/web/src/actions/types.ts create mode 100644 apps/web/src/actions/use-action-handler.ts create mode 100644 apps/web/src/actions/use-editor-actions.ts create mode 100644 apps/web/src/actions/use-keybindings.ts create mode 100644 apps/web/src/actions/use-keyboard-shortcuts-help.ts create mode 100644 apps/web/src/animation/__tests__/binding-values.test.ts create mode 100644 apps/web/src/animation/bezier.ts create mode 100644 apps/web/src/animation/binding-values.ts create mode 100644 apps/web/src/animation/curve-bridge.ts create mode 100644 apps/web/src/animation/effect-param-channel.ts create mode 100644 apps/web/src/animation/graph-channels.ts create mode 100644 apps/web/src/animation/graphic-param-channel.ts create mode 100644 apps/web/src/animation/index.ts create mode 100644 apps/web/src/animation/interpolation.ts create mode 100644 apps/web/src/animation/keyframe-query.ts create mode 100644 apps/web/src/animation/keyframes.ts create mode 100644 apps/web/src/animation/property-groups.ts create mode 100644 apps/web/src/animation/property-registry.ts create mode 100644 apps/web/src/animation/resolve.ts create mode 100644 apps/web/src/animation/target-resolver.ts create mode 100644 apps/web/src/animation/timeline-snap-points.ts create mode 100644 apps/web/src/animation/transform.ts create mode 100644 apps/web/src/animation/types.ts create mode 100644 apps/web/src/auth/client.ts create mode 100644 apps/web/src/auth/rate-limit.ts create mode 100644 apps/web/src/auth/server.ts create mode 100644 apps/web/src/background/blur.ts create mode 100644 apps/web/src/background/color.ts create mode 100644 apps/web/src/blog/query.ts create mode 100644 apps/web/src/blog/types.ts create mode 100644 apps/web/src/canvas/sizes.ts create mode 100644 apps/web/src/changelog/components/changelog-notification.tsx create mode 100644 apps/web/src/changelog/components/copy-markdown-button.tsx create mode 100644 apps/web/src/changelog/components/release.tsx create mode 100644 apps/web/src/changelog/entries/0.1.0.md create mode 100644 apps/web/src/changelog/entries/0.2.0.md create mode 100644 apps/web/src/changelog/entries/0.3.0.md create mode 100644 apps/web/src/changelog/entries/0.4.0.md create mode 100644 apps/web/src/changelog/utils.ts create mode 100644 apps/web/src/clipboard/handlers/elements.ts create mode 100644 apps/web/src/clipboard/handlers/index.ts create mode 100644 apps/web/src/clipboard/handlers/keyframes.ts create mode 100644 apps/web/src/clipboard/index.ts create mode 100644 apps/web/src/clipboard/types.ts create mode 100644 apps/web/src/commands/base-command.ts create mode 100644 apps/web/src/commands/batch-command.ts create mode 100644 apps/web/src/commands/index.ts create mode 100644 apps/web/src/commands/media/add-media-asset.ts create mode 100644 apps/web/src/commands/media/index.ts create mode 100644 apps/web/src/commands/media/remove-media-asset.ts create mode 100644 apps/web/src/commands/preview-tracker.ts create mode 100644 apps/web/src/commands/project/index.ts create mode 100644 apps/web/src/commands/project/update-project-settings.ts create mode 100644 apps/web/src/commands/scene/create-scene.ts create mode 100644 apps/web/src/commands/scene/delete-scene.ts create mode 100644 apps/web/src/commands/scene/index.ts create mode 100644 apps/web/src/commands/scene/move-bookmark.ts create mode 100644 apps/web/src/commands/scene/remove-bookmark.ts create mode 100644 apps/web/src/commands/scene/rename-scene.ts create mode 100644 apps/web/src/commands/scene/toggle-bookmark.ts create mode 100644 apps/web/src/commands/scene/update-bookmark.ts create mode 100644 apps/web/src/commands/timeline/clipboard/index.ts create mode 100644 apps/web/src/commands/timeline/clipboard/paste-keyframes.ts create mode 100644 apps/web/src/commands/timeline/clipboard/paste.ts create mode 100644 apps/web/src/commands/timeline/element/delete-elements.ts create mode 100644 apps/web/src/commands/timeline/element/duplicate-elements.ts create mode 100644 apps/web/src/commands/timeline/element/effects/add-effect.ts create mode 100644 apps/web/src/commands/timeline/element/effects/index.ts create mode 100644 apps/web/src/commands/timeline/element/effects/remove-effect.ts create mode 100644 apps/web/src/commands/timeline/element/effects/reorder-effect.ts create mode 100644 apps/web/src/commands/timeline/element/effects/toggle-effect.ts create mode 100644 apps/web/src/commands/timeline/element/effects/update-effect-params.ts create mode 100644 apps/web/src/commands/timeline/element/index.ts create mode 100644 apps/web/src/commands/timeline/element/insert-element.ts create mode 100644 apps/web/src/commands/timeline/element/keyframes/index.ts create mode 100644 apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts create mode 100644 apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts create mode 100644 apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts create mode 100644 apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts create mode 100644 apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts create mode 100644 apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts create mode 100644 apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts create mode 100644 apps/web/src/commands/timeline/element/masks/index.ts create mode 100644 apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts create mode 100644 apps/web/src/commands/timeline/element/masks/remove-mask.ts create mode 100644 apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts create mode 100644 apps/web/src/commands/timeline/element/move-elements.ts create mode 100644 apps/web/src/commands/timeline/element/split-elements.ts create mode 100644 apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts create mode 100644 apps/web/src/commands/timeline/element/update-elements.ts create mode 100644 apps/web/src/commands/timeline/index.ts create mode 100644 apps/web/src/commands/timeline/track/add-track.ts create mode 100644 apps/web/src/commands/timeline/track/index.ts create mode 100644 apps/web/src/commands/timeline/track/remove-track.ts create mode 100644 apps/web/src/commands/timeline/track/toggle-track-mute.ts create mode 100644 apps/web/src/commands/timeline/track/toggle-track-visibility.ts create mode 100644 apps/web/src/commands/timeline/tracks-snapshot.ts delete mode 100644 apps/web/src/components/editor/dialogs/delete-project-dialog.tsx delete mode 100644 apps/web/src/components/editor/dialogs/migration-dialog.tsx delete mode 100644 apps/web/src/components/editor/dialogs/project-info-dialog.tsx delete mode 100644 apps/web/src/components/editor/dialogs/rename-project-dialog.tsx delete mode 100644 apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx delete mode 100644 apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx create mode 100644 apps/web/src/components/editor/panels/assets/assets-panel-store.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/captions.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/effects.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/sounds.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/stickers.tsx delete mode 100644 apps/web/src/components/editor/panels/assets/views/text.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/context-menu.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/cursors.ts delete mode 100644 apps/web/src/components/editor/panels/preview/guide-overlay.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/guide-popover.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/handle-primitives.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/index.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/mask-handles.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/preview-viewport.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/snap-guides.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/toolbar.tsx delete mode 100644 apps/web/src/components/editor/panels/preview/transform-handles.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/audio-volume-line.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/audio-waveform.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/bookmarks.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/drag-line.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/drop-target.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/expanded-layout.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/session.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/index.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/interaction.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/layers.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/layout.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/snap-indicator.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/theme.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-element.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-tick.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/timeline-track.tsx delete mode 100644 apps/web/src/components/editor/panels/timeline/track-layout.ts create mode 100644 apps/web/src/db/index.ts create mode 100644 apps/web/src/db/schema.ts create mode 100644 apps/web/src/diagnostics/types.ts create mode 100644 apps/web/src/editor/cancel-interaction.ts create mode 100644 apps/web/src/editor/editor-store.ts create mode 100644 apps/web/src/editor/panel-store.ts create mode 100644 apps/web/src/editor/use-editor.ts create mode 100644 apps/web/src/editor/use-menu-preview.ts create mode 100644 apps/web/src/effects/components/assets-view.tsx create mode 100644 apps/web/src/effects/components/effects-tab.tsx create mode 100644 apps/web/src/effects/definitions/blur.ts create mode 100644 apps/web/src/effects/definitions/index.ts create mode 100644 apps/web/src/effects/index.ts create mode 100644 apps/web/src/effects/registry.ts create mode 100644 apps/web/src/effects/types.ts create mode 100644 apps/web/src/env/web.ts create mode 100644 apps/web/src/export/defaults.ts create mode 100644 apps/web/src/export/index.ts create mode 100644 apps/web/src/export/mime-types.ts create mode 100644 apps/web/src/feedback/components/feedback-popover.tsx create mode 100644 apps/web/src/feedback/index.ts create mode 100644 apps/web/src/feedback/queries.ts create mode 100644 apps/web/src/feedback/types.ts create mode 100644 apps/web/src/fonts/google-fonts.ts create mode 100644 apps/web/src/fonts/system-fonts.ts create mode 100644 apps/web/src/fonts/types.ts create mode 100644 apps/web/src/fonts/use-font-atlas.ts create mode 100644 apps/web/src/fps/__tests__/fps.test.ts create mode 100644 apps/web/src/fps/defaults.ts create mode 100644 apps/web/src/fps/presets.ts create mode 100644 apps/web/src/fps/utils.ts create mode 100644 apps/web/src/gradients/canvas.ts create mode 100644 apps/web/src/gradients/index.ts create mode 100644 apps/web/src/gradients/parser.ts create mode 100644 apps/web/src/graphics/components/graphic-tab.tsx create mode 100644 apps/web/src/graphics/definitions/ellipse.ts create mode 100644 apps/web/src/graphics/definitions/index.ts create mode 100644 apps/web/src/graphics/definitions/polygon.ts create mode 100644 apps/web/src/graphics/definitions/rectangle.ts create mode 100644 apps/web/src/graphics/definitions/shared.ts create mode 100644 apps/web/src/graphics/definitions/star.ts create mode 100644 apps/web/src/graphics/index.ts create mode 100644 apps/web/src/graphics/registry.ts create mode 100644 apps/web/src/graphics/stroke.ts create mode 100644 apps/web/src/graphics/types.ts create mode 100644 apps/web/src/guides/definitions/custom.tsx create mode 100644 apps/web/src/guides/definitions/grid.tsx create mode 100644 apps/web/src/guides/definitions/platforms.tsx create mode 100644 apps/web/src/guides/definitions/tiktok-layout.tsx create mode 100644 apps/web/src/guides/grid.ts create mode 100644 apps/web/src/guides/index.ts create mode 100644 apps/web/src/guides/preview-overlay.tsx create mode 100644 apps/web/src/guides/registry.tsx create mode 100644 apps/web/src/guides/types.ts delete mode 100644 apps/web/src/hooks/actions/use-action-handler.ts delete mode 100644 apps/web/src/hooks/actions/use-editor-actions.ts delete mode 100644 apps/web/src/hooks/storage/use-local-storage.ts delete mode 100644 apps/web/src/hooks/timeline/element/use-element-interaction.ts delete mode 100644 apps/web/src/hooks/timeline/element/use-element-selection.ts delete mode 100644 apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts delete mode 100644 apps/web/src/hooks/timeline/element/use-keyframe-drag.ts delete mode 100644 apps/web/src/hooks/timeline/element/use-keyframe-selection.ts delete mode 100644 apps/web/src/hooks/timeline/use-bookmark-drag.ts delete mode 100644 apps/web/src/hooks/timeline/use-edge-auto-scroll.ts delete mode 100644 apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts delete mode 100644 apps/web/src/hooks/timeline/use-scroll-position.ts delete mode 100644 apps/web/src/hooks/timeline/use-scroll-sync.ts delete mode 100644 apps/web/src/hooks/timeline/use-snap-indicator-position.ts delete mode 100644 apps/web/src/hooks/timeline/use-timeline-drag-drop.ts delete mode 100644 apps/web/src/hooks/timeline/use-timeline-playhead.ts delete mode 100644 apps/web/src/hooks/timeline/use-timeline-resize.ts delete mode 100644 apps/web/src/hooks/timeline/use-timeline-seek.ts delete mode 100644 apps/web/src/hooks/timeline/use-timeline-zoom.ts delete mode 100644 apps/web/src/hooks/use-editor.ts delete mode 100644 apps/web/src/hooks/use-element-preview.ts delete mode 100644 apps/web/src/hooks/use-file-upload.ts delete mode 100644 apps/web/src/hooks/use-font-atlas.ts delete mode 100644 apps/web/src/hooks/use-keybindings.ts delete mode 100644 apps/web/src/hooks/use-keyboard-shortcuts-help.ts delete mode 100644 apps/web/src/hooks/use-mask-handles.ts delete mode 100644 apps/web/src/hooks/use-menu-preview.ts delete mode 100644 apps/web/src/hooks/use-paste-media.ts delete mode 100644 apps/web/src/hooks/use-preview-interaction.ts delete mode 100644 apps/web/src/hooks/use-sound-search.ts delete mode 100644 apps/web/src/hooks/use-storage-persistence.ts delete mode 100644 apps/web/src/hooks/use-transform-handles.ts delete mode 100644 apps/web/src/lib/actions/definitions.ts delete mode 100644 apps/web/src/lib/actions/index.ts delete mode 100644 apps/web/src/lib/actions/keybinding.ts delete mode 100644 apps/web/src/lib/actions/registry.ts delete mode 100644 apps/web/src/lib/actions/types.ts delete mode 100644 apps/web/src/lib/animation/__tests__/binding-values.test.ts delete mode 100644 apps/web/src/lib/animation/bezier.ts delete mode 100644 apps/web/src/lib/animation/binding-values.ts delete mode 100644 apps/web/src/lib/animation/curve-bridge.ts delete mode 100644 apps/web/src/lib/animation/effect-param-channel.ts delete mode 100644 apps/web/src/lib/animation/graph-channels.ts delete mode 100644 apps/web/src/lib/animation/graphic-param-channel.ts delete mode 100644 apps/web/src/lib/animation/index.ts delete mode 100644 apps/web/src/lib/animation/interpolation.ts delete mode 100644 apps/web/src/lib/animation/keyframe-query.ts delete mode 100644 apps/web/src/lib/animation/keyframes.ts delete mode 100644 apps/web/src/lib/animation/property-groups.ts delete mode 100644 apps/web/src/lib/animation/property-registry.ts delete mode 100644 apps/web/src/lib/animation/resolve.ts delete mode 100644 apps/web/src/lib/animation/target-resolver.ts delete mode 100644 apps/web/src/lib/animation/transform.ts delete mode 100644 apps/web/src/lib/animation/types.ts delete mode 100644 apps/web/src/lib/auth/client.ts delete mode 100644 apps/web/src/lib/auth/server.ts delete mode 100644 apps/web/src/lib/background/blur.ts delete mode 100644 apps/web/src/lib/background/color.ts delete mode 100644 apps/web/src/lib/blog/query.ts delete mode 100644 apps/web/src/lib/blog/types.ts delete mode 100644 apps/web/src/lib/cancel-interaction.ts delete mode 100644 apps/web/src/lib/canvas/sizes.ts delete mode 100644 apps/web/src/lib/changelog/components/changelog-notification.tsx delete mode 100644 apps/web/src/lib/changelog/components/copy-markdown-button.tsx delete mode 100644 apps/web/src/lib/changelog/components/release.tsx delete mode 100644 apps/web/src/lib/changelog/entries/0.1.0.md delete mode 100644 apps/web/src/lib/changelog/entries/0.2.0.md delete mode 100644 apps/web/src/lib/changelog/entries/0.3.0.md delete mode 100644 apps/web/src/lib/changelog/entries/0.4.0.md delete mode 100644 apps/web/src/lib/changelog/utils.ts delete mode 100644 apps/web/src/lib/clipboard/handlers/elements.ts delete mode 100644 apps/web/src/lib/clipboard/handlers/index.ts delete mode 100644 apps/web/src/lib/clipboard/handlers/keyframes.ts delete mode 100644 apps/web/src/lib/clipboard/index.ts delete mode 100644 apps/web/src/lib/clipboard/types.ts delete mode 100644 apps/web/src/lib/commands/base-command.ts delete mode 100644 apps/web/src/lib/commands/batch-command.ts delete mode 100644 apps/web/src/lib/commands/index.ts delete mode 100644 apps/web/src/lib/commands/media/add-media-asset.ts delete mode 100644 apps/web/src/lib/commands/media/index.ts delete mode 100644 apps/web/src/lib/commands/media/remove-media-asset.ts delete mode 100644 apps/web/src/lib/commands/preview-tracker.ts delete mode 100644 apps/web/src/lib/commands/project/index.ts delete mode 100644 apps/web/src/lib/commands/project/update-project-settings.ts delete mode 100644 apps/web/src/lib/commands/scene/create-scene.ts delete mode 100644 apps/web/src/lib/commands/scene/delete-scene.ts delete mode 100644 apps/web/src/lib/commands/scene/index.ts delete mode 100644 apps/web/src/lib/commands/scene/move-bookmark.ts delete mode 100644 apps/web/src/lib/commands/scene/remove-bookmark.ts delete mode 100644 apps/web/src/lib/commands/scene/rename-scene.ts delete mode 100644 apps/web/src/lib/commands/scene/toggle-bookmark.ts delete mode 100644 apps/web/src/lib/commands/scene/update-bookmark.ts delete mode 100644 apps/web/src/lib/commands/timeline/clipboard/index.ts delete mode 100644 apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/delete-elements.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/effects/add-effect.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/effects/index.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/index.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/insert-element.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/index.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/masks/index.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/update-elements.ts delete mode 100644 apps/web/src/lib/commands/timeline/index.ts delete mode 100644 apps/web/src/lib/commands/timeline/track/add-track.ts delete mode 100644 apps/web/src/lib/commands/timeline/track/index.ts delete mode 100644 apps/web/src/lib/commands/timeline/track/remove-track.ts delete mode 100644 apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts delete mode 100644 apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts delete mode 100644 apps/web/src/lib/commands/timeline/tracks-snapshot.ts delete mode 100644 apps/web/src/lib/db/index.ts delete mode 100644 apps/web/src/lib/db/schema.ts delete mode 100644 apps/web/src/lib/diagnostics/types.ts delete mode 100644 apps/web/src/lib/drag-data.ts delete mode 100644 apps/web/src/lib/effects/definitions/blur.ts delete mode 100644 apps/web/src/lib/effects/definitions/index.ts delete mode 100644 apps/web/src/lib/effects/index.ts delete mode 100644 apps/web/src/lib/effects/registry.ts delete mode 100644 apps/web/src/lib/effects/types.ts delete mode 100644 apps/web/src/lib/env/web.ts delete mode 100644 apps/web/src/lib/export/defaults.ts delete mode 100644 apps/web/src/lib/export/index.ts delete mode 100644 apps/web/src/lib/export/mime-types.ts delete mode 100644 apps/web/src/lib/feedback/components/feedback-popover.tsx delete mode 100644 apps/web/src/lib/feedback/index.ts delete mode 100644 apps/web/src/lib/feedback/queries.ts delete mode 100644 apps/web/src/lib/feedback/types.ts delete mode 100644 apps/web/src/lib/fonts/google-fonts.ts delete mode 100644 apps/web/src/lib/fonts/system-fonts.ts delete mode 100644 apps/web/src/lib/fonts/types.ts delete mode 100644 apps/web/src/lib/fps/__tests__/fps.test.ts delete mode 100644 apps/web/src/lib/fps/defaults.ts delete mode 100644 apps/web/src/lib/fps/presets.ts delete mode 100644 apps/web/src/lib/fps/utils.ts delete mode 100644 apps/web/src/lib/gradients/canvas.ts delete mode 100644 apps/web/src/lib/gradients/index.ts delete mode 100644 apps/web/src/lib/gradients/parser.ts delete mode 100644 apps/web/src/lib/graphics/definitions/ellipse.ts delete mode 100644 apps/web/src/lib/graphics/definitions/index.ts delete mode 100644 apps/web/src/lib/graphics/definitions/polygon.ts delete mode 100644 apps/web/src/lib/graphics/definitions/rectangle.ts delete mode 100644 apps/web/src/lib/graphics/definitions/shared.ts delete mode 100644 apps/web/src/lib/graphics/definitions/star.ts delete mode 100644 apps/web/src/lib/graphics/index.ts delete mode 100644 apps/web/src/lib/graphics/registry.ts delete mode 100644 apps/web/src/lib/graphics/stroke.ts delete mode 100644 apps/web/src/lib/graphics/types.ts delete mode 100644 apps/web/src/lib/guides/definitions/custom.tsx delete mode 100644 apps/web/src/lib/guides/definitions/grid.tsx delete mode 100644 apps/web/src/lib/guides/definitions/platforms.tsx delete mode 100644 apps/web/src/lib/guides/definitions/tiktok-layout.tsx delete mode 100644 apps/web/src/lib/guides/grid.ts delete mode 100644 apps/web/src/lib/guides/index.ts delete mode 100644 apps/web/src/lib/guides/registry.tsx delete mode 100644 apps/web/src/lib/guides/types.ts delete mode 100644 apps/web/src/lib/masks/__tests__/snap.test.ts delete mode 100644 apps/web/src/lib/masks/custom-path.ts delete mode 100644 apps/web/src/lib/masks/definitions/box-like.ts delete mode 100644 apps/web/src/lib/masks/definitions/cinematic-bars.ts delete mode 100644 apps/web/src/lib/masks/definitions/custom.ts delete mode 100644 apps/web/src/lib/masks/definitions/diamond.ts delete mode 100644 apps/web/src/lib/masks/definitions/ellipse.ts delete mode 100644 apps/web/src/lib/masks/definitions/heart.ts delete mode 100644 apps/web/src/lib/masks/definitions/index.ts delete mode 100644 apps/web/src/lib/masks/definitions/rectangle.ts delete mode 100644 apps/web/src/lib/masks/definitions/split.ts delete mode 100644 apps/web/src/lib/masks/definitions/star.ts delete mode 100644 apps/web/src/lib/masks/definitions/text.ts delete mode 100644 apps/web/src/lib/masks/dimensions.ts delete mode 100644 apps/web/src/lib/masks/feather.ts delete mode 100644 apps/web/src/lib/masks/geometry.ts delete mode 100644 apps/web/src/lib/masks/handle-positions.ts delete mode 100644 apps/web/src/lib/masks/index.ts delete mode 100644 apps/web/src/lib/masks/param-update.ts delete mode 100644 apps/web/src/lib/masks/registry.ts delete mode 100644 apps/web/src/lib/masks/snap.ts delete mode 100644 apps/web/src/lib/masks/types.ts delete mode 100644 apps/web/src/lib/masks/utils.ts delete mode 100644 apps/web/src/lib/media/audio-mastering.ts delete mode 100644 apps/web/src/lib/media/audio.ts delete mode 100644 apps/web/src/lib/media/media-utils.ts delete mode 100644 apps/web/src/lib/media/mediabunny.ts delete mode 100644 apps/web/src/lib/media/processing.ts delete mode 100644 apps/web/src/lib/media/types.ts delete mode 100644 apps/web/src/lib/media/upload-toast.ts delete mode 100644 apps/web/src/lib/media/waveform-summary.ts delete mode 100644 apps/web/src/lib/panels/layout.ts delete mode 100644 apps/web/src/lib/params.ts delete mode 100644 apps/web/src/lib/preview/element-bounds.ts delete mode 100644 apps/web/src/lib/preview/hit-test.ts delete mode 100644 apps/web/src/lib/preview/preview-coords.ts delete mode 100644 apps/web/src/lib/preview/preview-snap.ts delete mode 100644 apps/web/src/lib/preview/zoom.ts delete mode 100644 apps/web/src/lib/project/types.ts delete mode 100644 apps/web/src/lib/rate-limit.ts delete mode 100644 apps/web/src/lib/registry.ts delete mode 100644 apps/web/src/lib/rendering.ts delete mode 100644 apps/web/src/lib/retime/__tests__/resolve.test.ts delete mode 100644 apps/web/src/lib/retime/__tests__/split.test.ts delete mode 100644 apps/web/src/lib/retime/audio-stretch.ts delete mode 100644 apps/web/src/lib/retime/index.ts delete mode 100644 apps/web/src/lib/retime/presets.ts delete mode 100644 apps/web/src/lib/retime/rate.ts delete mode 100644 apps/web/src/lib/retime/resolve.ts delete mode 100644 apps/web/src/lib/retime/split.ts delete mode 100644 apps/web/src/lib/ripple/apply.ts delete mode 100644 apps/web/src/lib/ripple/diff.ts delete mode 100644 apps/web/src/lib/ripple/index.ts delete mode 100644 apps/web/src/lib/ripple/shift.ts delete mode 100644 apps/web/src/lib/scenes.ts delete mode 100644 apps/web/src/lib/selection/attributes.ts delete mode 100644 apps/web/src/lib/selection/context.tsx delete mode 100644 apps/web/src/lib/selection/editor-selection.ts delete mode 100644 apps/web/src/lib/selection/hit-testing.ts delete mode 100644 apps/web/src/lib/selection/hooks/use-box-select.ts delete mode 100644 apps/web/src/lib/selection/hooks/use-selection-scope.ts delete mode 100644 apps/web/src/lib/selection/index.ts delete mode 100644 apps/web/src/lib/selection/scope.ts delete mode 100644 apps/web/src/lib/selection/selectable-item.tsx delete mode 100644 apps/web/src/lib/selection/selectable-surface.tsx delete mode 100644 apps/web/src/lib/selection/selection-box.tsx delete mode 100644 apps/web/src/lib/selection/state.ts delete mode 100644 apps/web/src/lib/selection/types.ts delete mode 100644 apps/web/src/lib/site/brand.ts delete mode 100644 apps/web/src/lib/site/external-tools.ts delete mode 100644 apps/web/src/lib/site/social.ts delete mode 100644 apps/web/src/lib/site/sponsors.ts delete mode 100644 apps/web/src/lib/sounds/types.ts delete mode 100644 apps/web/src/lib/stickers/__tests__/sticker-id.test.ts delete mode 100644 apps/web/src/lib/stickers/categories.ts delete mode 100644 apps/web/src/lib/stickers/index.ts delete mode 100644 apps/web/src/lib/stickers/intrinsic-size.ts delete mode 100644 apps/web/src/lib/stickers/providers/countries-data.ts delete mode 100644 apps/web/src/lib/stickers/providers/flags.ts delete mode 100644 apps/web/src/lib/stickers/providers/index.ts delete mode 100644 apps/web/src/lib/stickers/providers/logos.ts delete mode 100644 apps/web/src/lib/stickers/providers/shapes.ts delete mode 100644 apps/web/src/lib/stickers/registry.ts delete mode 100644 apps/web/src/lib/stickers/resolver.ts delete mode 100644 apps/web/src/lib/stickers/sticker-id.ts delete mode 100644 apps/web/src/lib/stickers/types.ts delete mode 100644 apps/web/src/lib/subtitles/ass.ts delete mode 100644 apps/web/src/lib/subtitles/build-subtitle-text-element.ts delete mode 100644 apps/web/src/lib/subtitles/insert.ts delete mode 100644 apps/web/src/lib/subtitles/parse.ts delete mode 100644 apps/web/src/lib/subtitles/srt.ts delete mode 100644 apps/web/src/lib/subtitles/types.ts delete mode 100644 apps/web/src/lib/text/background.ts delete mode 100644 apps/web/src/lib/text/layout.ts delete mode 100644 apps/web/src/lib/text/measure-element.ts delete mode 100644 apps/web/src/lib/text/primitives.ts delete mode 100644 apps/web/src/lib/text/typography.ts delete mode 100644 apps/web/src/lib/timeline/audio-constants.ts delete mode 100644 apps/web/src/lib/timeline/audio-display.ts delete mode 100644 apps/web/src/lib/timeline/audio-separation/index.ts delete mode 100644 apps/web/src/lib/timeline/audio-state.ts delete mode 100644 apps/web/src/lib/timeline/bookmarks.ts delete mode 100644 apps/web/src/lib/timeline/creation.ts delete mode 100644 apps/web/src/lib/timeline/defaults.ts delete mode 100644 apps/web/src/lib/timeline/drag-utils.ts delete mode 100644 apps/web/src/lib/timeline/drag.ts delete mode 100644 apps/web/src/lib/timeline/element-utils.ts delete mode 100644 apps/web/src/lib/timeline/group-move/build-group.ts delete mode 100644 apps/web/src/lib/timeline/group-move/index.ts delete mode 100644 apps/web/src/lib/timeline/group-move/resolve-move.ts delete mode 100644 apps/web/src/lib/timeline/group-move/snap.ts delete mode 100644 apps/web/src/lib/timeline/group-move/track-placement.ts delete mode 100644 apps/web/src/lib/timeline/group-move/types.ts delete mode 100644 apps/web/src/lib/timeline/group-resize/compute-resize.ts delete mode 100644 apps/web/src/lib/timeline/group-resize/index.ts delete mode 100644 apps/web/src/lib/timeline/group-resize/types.ts delete mode 100644 apps/web/src/lib/timeline/index.ts delete mode 100644 apps/web/src/lib/timeline/pixel-utils.ts delete mode 100644 apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts delete mode 100644 apps/web/src/lib/timeline/placement/apply.ts delete mode 100644 apps/web/src/lib/timeline/placement/compatibility.ts delete mode 100644 apps/web/src/lib/timeline/placement/index.ts delete mode 100644 apps/web/src/lib/timeline/placement/insert-index.ts delete mode 100644 apps/web/src/lib/timeline/placement/main-track.ts delete mode 100644 apps/web/src/lib/timeline/placement/overlap.ts delete mode 100644 apps/web/src/lib/timeline/placement/resolve.ts delete mode 100644 apps/web/src/lib/timeline/placement/track-factory.ts delete mode 100644 apps/web/src/lib/timeline/placement/types.ts delete mode 100644 apps/web/src/lib/timeline/ruler-utils.ts delete mode 100644 apps/web/src/lib/timeline/scale.ts delete mode 100644 apps/web/src/lib/timeline/snap-utils.ts delete mode 100644 apps/web/src/lib/timeline/track-capabilities.ts delete mode 100644 apps/web/src/lib/timeline/track-element-update.ts delete mode 100644 apps/web/src/lib/timeline/tracks.ts delete mode 100644 apps/web/src/lib/timeline/types.ts delete mode 100644 apps/web/src/lib/timeline/update-pipeline.ts delete mode 100644 apps/web/src/lib/timeline/zoom-utils.ts delete mode 100644 apps/web/src/lib/transcription/audio.ts delete mode 100644 apps/web/src/lib/transcription/caption-defaults.ts delete mode 100644 apps/web/src/lib/transcription/caption.ts delete mode 100644 apps/web/src/lib/transcription/diagnostics.ts delete mode 100644 apps/web/src/lib/transcription/languages.ts delete mode 100644 apps/web/src/lib/transcription/models.ts delete mode 100644 apps/web/src/lib/transcription/supported-languages.ts delete mode 100644 apps/web/src/lib/transcription/types.ts delete mode 100644 apps/web/src/lib/wasm/index.ts delete mode 100644 apps/web/src/lib/wasm/ticks.ts create mode 100644 apps/web/src/masks/__tests__/snap.test.ts create mode 100644 apps/web/src/masks/components/masks-tab.tsx create mode 100644 apps/web/src/masks/custom-path.ts create mode 100644 apps/web/src/masks/definitions/box-like.ts create mode 100644 apps/web/src/masks/definitions/cinematic-bars.ts create mode 100644 apps/web/src/masks/definitions/custom.ts create mode 100644 apps/web/src/masks/definitions/diamond.ts create mode 100644 apps/web/src/masks/definitions/ellipse.ts create mode 100644 apps/web/src/masks/definitions/heart.ts create mode 100644 apps/web/src/masks/definitions/index.ts create mode 100644 apps/web/src/masks/definitions/rectangle.ts create mode 100644 apps/web/src/masks/definitions/split.ts create mode 100644 apps/web/src/masks/definitions/star.ts create mode 100644 apps/web/src/masks/definitions/text.ts create mode 100644 apps/web/src/masks/dimensions.ts create mode 100644 apps/web/src/masks/feather.ts create mode 100644 apps/web/src/masks/geometry.ts create mode 100644 apps/web/src/masks/handle-positions.ts create mode 100644 apps/web/src/masks/index.ts create mode 100644 apps/web/src/masks/param-update.ts create mode 100644 apps/web/src/masks/registry.ts create mode 100644 apps/web/src/masks/snap.ts create mode 100644 apps/web/src/masks/types.ts create mode 100644 apps/web/src/masks/use-mask-handles.ts create mode 100644 apps/web/src/masks/utils.ts create mode 100644 apps/web/src/media/audio-mastering.ts create mode 100644 apps/web/src/media/audio.ts create mode 100644 apps/web/src/media/media-utils.ts create mode 100644 apps/web/src/media/mediabunny.ts create mode 100644 apps/web/src/media/processing.ts create mode 100644 apps/web/src/media/types.ts create mode 100644 apps/web/src/media/upload-toast.ts create mode 100644 apps/web/src/media/use-file-upload.ts create mode 100644 apps/web/src/media/use-paste-media.ts create mode 100644 apps/web/src/media/waveform-summary.ts create mode 100644 apps/web/src/panels/layout.ts create mode 100644 apps/web/src/params/index.ts create mode 100644 apps/web/src/params/registry.ts create mode 100644 apps/web/src/preview/components/context-menu.tsx create mode 100644 apps/web/src/preview/components/cursors.ts create mode 100644 apps/web/src/preview/components/guide-popover.tsx create mode 100644 apps/web/src/preview/components/handle-primitives.tsx create mode 100644 apps/web/src/preview/components/index.tsx create mode 100644 apps/web/src/preview/components/mask-handles.tsx create mode 100644 apps/web/src/preview/components/overlay-layer.tsx create mode 100644 apps/web/src/preview/components/preview-interaction-overlay.tsx create mode 100644 apps/web/src/preview/components/preview-viewport.tsx create mode 100644 apps/web/src/preview/components/snap-guides.tsx create mode 100644 apps/web/src/preview/components/text-edit-overlay.tsx create mode 100644 apps/web/src/preview/components/toolbar.tsx create mode 100644 apps/web/src/preview/components/transform-handles.tsx create mode 100644 apps/web/src/preview/element-bounds.ts create mode 100644 apps/web/src/preview/hit-test.ts create mode 100644 apps/web/src/preview/hooks/use-preview-interaction.ts create mode 100644 apps/web/src/preview/hooks/use-transform-handles.ts create mode 100644 apps/web/src/preview/overlays.ts create mode 100644 apps/web/src/preview/preview-coords.ts create mode 100644 apps/web/src/preview/preview-snap.ts create mode 100644 apps/web/src/preview/preview-store.ts create mode 100644 apps/web/src/preview/zoom.ts create mode 100644 apps/web/src/project/components/delete-project-dialog.tsx create mode 100644 apps/web/src/project/components/migration-dialog.tsx create mode 100644 apps/web/src/project/components/project-info-dialog.tsx create mode 100644 apps/web/src/project/components/rename-project-dialog.tsx create mode 100644 apps/web/src/project/types.ts create mode 100644 apps/web/src/rendering/components/blending-tab.tsx create mode 100644 apps/web/src/rendering/components/transform-tab.tsx create mode 100644 apps/web/src/rendering/index.ts create mode 100644 apps/web/src/retime/__tests__/resolve.test.ts create mode 100644 apps/web/src/retime/__tests__/split.test.ts create mode 100644 apps/web/src/retime/audio-stretch.ts create mode 100644 apps/web/src/retime/index.ts create mode 100644 apps/web/src/retime/presets.ts create mode 100644 apps/web/src/retime/rate.ts create mode 100644 apps/web/src/retime/resolve.ts create mode 100644 apps/web/src/retime/split.ts create mode 100644 apps/web/src/ripple/apply.ts create mode 100644 apps/web/src/ripple/diff.ts create mode 100644 apps/web/src/ripple/index.ts create mode 100644 apps/web/src/ripple/shift.ts create mode 100644 apps/web/src/selection/attributes.ts create mode 100644 apps/web/src/selection/context.tsx create mode 100644 apps/web/src/selection/editor-selection.ts create mode 100644 apps/web/src/selection/hit-testing.ts create mode 100644 apps/web/src/selection/hooks/use-box-select.ts create mode 100644 apps/web/src/selection/hooks/use-selection-scope.ts create mode 100644 apps/web/src/selection/index.ts create mode 100644 apps/web/src/selection/scope.ts create mode 100644 apps/web/src/selection/selectable-item.tsx create mode 100644 apps/web/src/selection/selectable-surface.tsx create mode 100644 apps/web/src/selection/selection-box.tsx create mode 100644 apps/web/src/selection/state.ts create mode 100644 apps/web/src/selection/types.ts create mode 100644 apps/web/src/services/storage/components/storage-persistence-dialog.tsx create mode 100644 apps/web/src/services/storage/use-local-storage.ts create mode 100644 apps/web/src/services/storage/use-storage-persistence.ts create mode 100644 apps/web/src/site/brand.ts create mode 100644 apps/web/src/site/external-tools.ts create mode 100644 apps/web/src/site/social.ts create mode 100644 apps/web/src/site/sponsors.ts create mode 100644 apps/web/src/sounds/components/assets-view.tsx create mode 100644 apps/web/src/sounds/sounds-store.ts create mode 100644 apps/web/src/sounds/types.ts create mode 100644 apps/web/src/sounds/use-sound-search.ts create mode 100644 apps/web/src/speed/components/speed-tab.tsx create mode 100644 apps/web/src/stickers/__tests__/sticker-id.test.ts create mode 100644 apps/web/src/stickers/categories.ts create mode 100644 apps/web/src/stickers/components/assets-view.tsx create mode 100644 apps/web/src/stickers/index.ts create mode 100644 apps/web/src/stickers/intrinsic-size.ts create mode 100644 apps/web/src/stickers/providers/countries-data.ts create mode 100644 apps/web/src/stickers/providers/flags.ts create mode 100644 apps/web/src/stickers/providers/index.ts create mode 100644 apps/web/src/stickers/providers/logos.ts create mode 100644 apps/web/src/stickers/providers/shapes.ts create mode 100644 apps/web/src/stickers/registry.ts create mode 100644 apps/web/src/stickers/resolver.ts create mode 100644 apps/web/src/stickers/sticker-id.ts create mode 100644 apps/web/src/stickers/stickers-store.ts create mode 100644 apps/web/src/stickers/types.ts delete mode 100644 apps/web/src/stores/assets-panel-store.tsx delete mode 100644 apps/web/src/stores/editor-store.ts delete mode 100644 apps/web/src/stores/keybindings-store.ts delete mode 100644 apps/web/src/stores/keybindings/migrations/index.ts delete mode 100644 apps/web/src/stores/keybindings/migrations/v2-to-v3.ts delete mode 100644 apps/web/src/stores/keybindings/migrations/v3-to-v4.ts delete mode 100644 apps/web/src/stores/keybindings/migrations/v4-to-v5.ts delete mode 100644 apps/web/src/stores/keybindings/migrations/v5-to-v6.ts delete mode 100644 apps/web/src/stores/keybindings/migrations/v6-to-v7.ts delete mode 100644 apps/web/src/stores/panel-store.ts delete mode 100644 apps/web/src/stores/preview-store.ts delete mode 100644 apps/web/src/stores/properties-store.ts delete mode 100644 apps/web/src/stores/sounds-store.ts delete mode 100644 apps/web/src/stores/stickers-store.ts delete mode 100644 apps/web/src/stores/timeline-store.ts create mode 100644 apps/web/src/subtitles/ass.ts create mode 100644 apps/web/src/subtitles/build-subtitle-text-element.ts create mode 100644 apps/web/src/subtitles/components/assets-view.tsx create mode 100644 apps/web/src/subtitles/insert.ts create mode 100644 apps/web/src/subtitles/parse.ts create mode 100644 apps/web/src/subtitles/srt.ts create mode 100644 apps/web/src/subtitles/types.ts create mode 100644 apps/web/src/text/background.ts create mode 100644 apps/web/src/text/components/assets-view.tsx create mode 100644 apps/web/src/text/components/text-tab.tsx create mode 100644 apps/web/src/text/layout.ts create mode 100644 apps/web/src/text/measure-element.ts create mode 100644 apps/web/src/text/primitives.ts create mode 100644 apps/web/src/text/typography.ts create mode 100644 apps/web/src/timeline/audio-constants.ts create mode 100644 apps/web/src/timeline/audio-display.ts create mode 100644 apps/web/src/timeline/audio-separation/index.ts create mode 100644 apps/web/src/timeline/audio-state.ts create mode 100644 apps/web/src/timeline/bookmarks/components/bookmarks.tsx create mode 100644 apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts create mode 100644 apps/web/src/timeline/bookmarks/index.ts create mode 100644 apps/web/src/timeline/bookmarks/preview-overlay-source.tsx create mode 100644 apps/web/src/timeline/bookmarks/snap-source.ts create mode 100644 apps/web/src/timeline/bookmarks/utils.ts create mode 100644 apps/web/src/timeline/components/audio-tab.tsx create mode 100644 apps/web/src/timeline/components/audio-volume-line.tsx create mode 100644 apps/web/src/timeline/components/audio-waveform.tsx create mode 100644 apps/web/src/timeline/components/drag-line.tsx create mode 100644 apps/web/src/timeline/components/drop-target.ts create mode 100644 apps/web/src/timeline/components/expanded-layout.ts create mode 100644 apps/web/src/timeline/components/graph-editor/bezier-graph.tsx create mode 100644 apps/web/src/timeline/components/graph-editor/custom-presets-store.ts create mode 100644 apps/web/src/timeline/components/graph-editor/easing-presets.ts create mode 100644 apps/web/src/timeline/components/graph-editor/popover.tsx create mode 100644 apps/web/src/timeline/components/graph-editor/session.ts create mode 100644 apps/web/src/timeline/components/graph-editor/use-controller.ts create mode 100644 apps/web/src/timeline/components/index.tsx create mode 100644 apps/web/src/timeline/components/interaction.ts create mode 100644 apps/web/src/timeline/components/layers.ts create mode 100644 apps/web/src/timeline/components/layout.ts create mode 100644 apps/web/src/timeline/components/selection-hit-testing.ts create mode 100644 apps/web/src/timeline/components/snap-indicator.tsx create mode 100644 apps/web/src/timeline/components/theme.ts create mode 100644 apps/web/src/timeline/components/timeline-element.tsx create mode 100644 apps/web/src/timeline/components/timeline-playhead.tsx create mode 100644 apps/web/src/timeline/components/timeline-ruler.tsx create mode 100644 apps/web/src/timeline/components/timeline-tick.tsx create mode 100644 apps/web/src/timeline/components/timeline-toolbar.tsx create mode 100644 apps/web/src/timeline/components/timeline-track.tsx create mode 100644 apps/web/src/timeline/components/track-layout.ts create mode 100644 apps/web/src/timeline/creation.ts create mode 100644 apps/web/src/timeline/defaults.ts create mode 100644 apps/web/src/timeline/drag-data.ts create mode 100644 apps/web/src/timeline/drag-utils.ts create mode 100644 apps/web/src/timeline/drag.ts create mode 100644 apps/web/src/timeline/element-snap-source.ts create mode 100644 apps/web/src/timeline/element-utils.ts create mode 100644 apps/web/src/timeline/group-move/build-group.ts create mode 100644 apps/web/src/timeline/group-move/index.ts create mode 100644 apps/web/src/timeline/group-move/resolve-move.ts create mode 100644 apps/web/src/timeline/group-move/snap.ts create mode 100644 apps/web/src/timeline/group-move/track-placement.ts create mode 100644 apps/web/src/timeline/group-move/types.ts create mode 100644 apps/web/src/timeline/group-resize/compute-resize.ts create mode 100644 apps/web/src/timeline/group-resize/index.ts create mode 100644 apps/web/src/timeline/group-resize/types.ts create mode 100644 apps/web/src/timeline/hooks/element/use-element-interaction.ts create mode 100644 apps/web/src/timeline/hooks/element/use-element-selection.ts create mode 100644 apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts create mode 100644 apps/web/src/timeline/hooks/element/use-keyframe-drag.ts create mode 100644 apps/web/src/timeline/hooks/element/use-keyframe-selection.ts create mode 100644 apps/web/src/timeline/hooks/use-edge-auto-scroll.ts create mode 100644 apps/web/src/timeline/hooks/use-element-preview.ts create mode 100644 apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts create mode 100644 apps/web/src/timeline/hooks/use-scroll-position.ts create mode 100644 apps/web/src/timeline/hooks/use-scroll-sync.ts create mode 100644 apps/web/src/timeline/hooks/use-snap-indicator-position.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-drag-drop.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-playhead.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-resize.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-seek.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-zoom.ts create mode 100644 apps/web/src/timeline/index.ts create mode 100644 apps/web/src/timeline/pixel-utils.ts create mode 100644 apps/web/src/timeline/placement/__tests__/resolve.test.ts create mode 100644 apps/web/src/timeline/placement/apply.ts create mode 100644 apps/web/src/timeline/placement/compatibility.ts create mode 100644 apps/web/src/timeline/placement/index.ts create mode 100644 apps/web/src/timeline/placement/insert-index.ts create mode 100644 apps/web/src/timeline/placement/main-track.ts create mode 100644 apps/web/src/timeline/placement/overlap.ts create mode 100644 apps/web/src/timeline/placement/resolve.ts create mode 100644 apps/web/src/timeline/placement/track-factory.ts create mode 100644 apps/web/src/timeline/placement/types.ts create mode 100644 apps/web/src/timeline/playhead-snap-source.ts create mode 100644 apps/web/src/timeline/ruler-utils.ts create mode 100644 apps/web/src/timeline/scale.ts create mode 100644 apps/web/src/timeline/scenes.ts create mode 100644 apps/web/src/timeline/snapping/build.ts create mode 100644 apps/web/src/timeline/snapping/index.ts create mode 100644 apps/web/src/timeline/snapping/resolve.ts create mode 100644 apps/web/src/timeline/snapping/threshold.ts create mode 100644 apps/web/src/timeline/snapping/types.ts create mode 100644 apps/web/src/timeline/timeline-store.ts create mode 100644 apps/web/src/timeline/track-capabilities.ts create mode 100644 apps/web/src/timeline/track-element-update.ts create mode 100644 apps/web/src/timeline/tracks.ts create mode 100644 apps/web/src/timeline/types.ts create mode 100644 apps/web/src/timeline/update-pipeline.ts create mode 100644 apps/web/src/timeline/zoom-utils.ts create mode 100644 apps/web/src/transcription/audio.ts create mode 100644 apps/web/src/transcription/caption-defaults.ts create mode 100644 apps/web/src/transcription/caption.ts create mode 100644 apps/web/src/transcription/diagnostics.ts create mode 100644 apps/web/src/transcription/languages.ts create mode 100644 apps/web/src/transcription/models.ts create mode 100644 apps/web/src/transcription/supported-languages.ts create mode 100644 apps/web/src/transcription/types.ts create mode 100644 apps/web/src/wasm/index.ts create mode 100644 apps/web/src/wasm/ticks.ts (limited to 'apps/web/src') diff --git a/apps/web/src/actions/components/shortcuts-dialog.tsx b/apps/web/src/actions/components/shortcuts-dialog.tsx new file mode 100644 index 00000000..cd9c0242 --- /dev/null +++ b/apps/web/src/actions/components/shortcuts-dialog.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + type KeyboardShortcut, + useKeyboardShortcutsHelp, +} from "@/actions/use-keyboard-shortcuts-help"; +import { useKeybindingsStore } from "@/actions/keybindings-store"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export function ShortcutsDialog({ + isOpen, + onOpenChange, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [recordingShortcut, setRecordingShortcut] = + useState(null); + + const { + updateKeybinding, + removeKeybinding, + getKeybindingString, + validateKeybinding, + getKeybindingsForAction, + setIsRecording, + resetToDefaults, + isRecording, + } = useKeybindingsStore(); + + const { shortcuts } = useKeyboardShortcutsHelp(); + + const categories = Array.from(new Set(shortcuts.map((s) => s.category))); + + useEffect(() => { + if (!isRecording || !recordingShortcut) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const keyString = getKeybindingString(e); + if (keyString) { + const conflict = validateKeybinding( + keyString, + recordingShortcut.action, + ); + if (conflict) { + toast.error( + `Key "${keyString}" is already bound to "${conflict.existingAction}"`, + ); + setRecordingShortcut(null); + return; + } + + const oldKeys = getKeybindingsForAction(recordingShortcut.action); + for (const key of oldKeys) { + removeKeybinding(key); + } + + updateKeybinding(keyString, recordingShortcut.action); + + setIsRecording(false); + setRecordingShortcut(null); + } + }; + + const handleClickOutside = () => { + setRecordingShortcut(null); + setIsRecording(false); + }; + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("click", handleClickOutside); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("click", handleClickOutside); + }; + }, [ + recordingShortcut, + getKeybindingString, + updateKeybinding, + removeKeybinding, + validateKeybinding, + getKeybindingsForAction, + setIsRecording, + isRecording, + ]); + + const handleStartRecording = (shortcut: KeyboardShortcut) => { + setRecordingShortcut(shortcut); + setIsRecording(true); + }; + + return ( + + + + Keyboard shortcuts + + + +
+ {categories.map((category) => ( +
+

+ {category} +

+
+ {shortcuts + .filter((shortcut) => shortcut.category === category) + .map((shortcut) => ( + handleStartRecording(shortcut)} + /> + ))} +
+
+ ))} +
+
+ + + +
+
+ ); +} + +function ShortcutItem({ + shortcut, + isRecording, + onStartRecording, +}: { + shortcut: KeyboardShortcut; + isRecording: boolean; + onStartRecording: (params: { shortcut: KeyboardShortcut }) => void; +}) { + const displayKeys = shortcut.keys.filter((key: string) => { + if ( + key.includes("Cmd") && + shortcut.keys.includes(key.replace("Cmd", "Ctrl")) + ) + return false; + + return true; + }); + + return ( +
+
+ {shortcut.icon && ( +
{shortcut.icon}
+ )} + {shortcut.description} +
+
+ {displayKeys.map((key: string, index: number) => ( +
+
+ {key.split("+").map((keyPart: string, partIndex: number) => { + const keyId = `${shortcut.id}-${index}-${partIndex}`; + return ( + onStartRecording({ shortcut })} + > + {keyPart} + + ); + })} +
+ {index < displayKeys.length - 1 && ( + or + )} +
+ ))} +
+
+ ); +} + +function EditableShortcutKey({ + children, + isRecording, + onStartRecording, +}: { + children: React.ReactNode; + isRecording: boolean; + onStartRecording: () => void; +}) { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onStartRecording(); + }; + + return ( + + ); +} diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts new file mode 100644 index 00000000..6f0ba700 --- /dev/null +++ b/apps/web/src/actions/definitions.ts @@ -0,0 +1,209 @@ +import type { + KeybindingConfig, + ShortcutKey, +} from "@/actions/keybinding"; +import type { TActionWithOptionalArgs } from "./types"; + +export type TActionCategory = + | "playback" + | "navigation" + | "editing" + | "selection" + | "history" + | "timeline" + | "controls" + | "assets"; + +export interface TActionBaseDefinition { + description: string; + category: TActionCategory; + args?: Record; +} + +export interface TActionDefinition extends TActionBaseDefinition { + defaultShortcuts?: readonly ShortcutKey[]; +} + +export const ACTIONS = { + "toggle-play": { + description: "Play/Pause", + category: "playback", + }, + "stop-playback": { + description: "Stop playback", + category: "playback", + }, + "seek-forward": { + description: "Seek forward 1 second", + category: "playback", + args: { seconds: "number" }, + }, + "seek-backward": { + description: "Seek backward 1 second", + category: "playback", + args: { seconds: "number" }, + }, + "frame-step-forward": { + description: "Frame step forward", + category: "navigation", + }, + "frame-step-backward": { + description: "Frame step backward", + category: "navigation", + }, + "jump-forward": { + description: "Jump forward 5 seconds", + category: "navigation", + args: { seconds: "number" }, + }, + "jump-backward": { + description: "Jump backward 5 seconds", + category: "navigation", + args: { seconds: "number" }, + }, + "goto-start": { + description: "Go to timeline start", + category: "navigation", + }, + "goto-end": { + description: "Go to timeline end", + category: "navigation", + }, + split: { + description: "Split elements at playhead", + category: "editing", + }, + "split-left": { + description: "Split and remove left", + category: "editing", + }, + "split-right": { + description: "Split and remove right", + category: "editing", + }, + "delete-selected": { + description: "Delete current selection", + category: "editing", + }, + "copy-selected": { + description: "Copy selected elements", + category: "editing", + }, + "paste-copied": { + description: "Paste elements at playhead", + category: "editing", + }, + "toggle-snapping": { + description: "Toggle snapping", + category: "editing", + }, + "toggle-ripple-editing": { + description: "Toggle ripple editing", + category: "editing", + }, + "toggle-source-audio": { + description: "Extract or recover source audio", + category: "editing", + }, + "select-all": { + description: "Select all elements", + category: "selection", + }, + "cancel-interaction": { + description: "Cancel current interaction", + category: "controls", + }, + "deselect-all": { + description: "Deselect all elements", + category: "selection", + }, + "duplicate-selected": { + description: "Duplicate selected element", + category: "selection", + }, + "toggle-elements-muted-selected": { + description: "Mute/unmute selected elements", + category: "selection", + }, + "toggle-elements-visibility-selected": { + description: "Show/hide selected elements", + category: "selection", + }, + "toggle-bookmark": { + description: "Toggle bookmark at playhead", + category: "timeline", + }, + undo: { + description: "Undo", + category: "history", + }, + redo: { + description: "Redo", + category: "history", + }, + "remove-media-asset": { + description: "Remove media asset", + category: "assets", + args: { projectId: "string", assetId: "string" }, + }, + "remove-media-assets": { + description: "Remove media assets", + category: "assets", + args: { projectId: "string", assetIds: "string[]" }, + }, +} as const satisfies Record; + +export type TAction = keyof typeof ACTIONS; + +const ACTION_DEFAULT_SHORTCUTS = { + "toggle-play": ["space", "k"], + "seek-forward": ["l"], + "seek-backward": ["j"], + "frame-step-forward": ["right"], + "frame-step-backward": ["left"], + "jump-forward": ["shift+right"], + "jump-backward": ["shift+left"], + "goto-start": ["home", "enter"], + "goto-end": ["end"], + split: ["s"], + "split-left": ["q"], + "split-right": ["w"], + "delete-selected": ["backspace", "delete"], + "copy-selected": ["ctrl+c"], + "paste-copied": ["ctrl+v"], + "toggle-snapping": ["n"], + "select-all": ["ctrl+a"], + "cancel-interaction": ["escape"], + "duplicate-selected": ["ctrl+d"], + undo: ["ctrl+z"], + redo: ["ctrl+shift+z", "ctrl+y"], +} as const satisfies Partial>; + +const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< + Record +> = ACTION_DEFAULT_SHORTCUTS; + +export function getActionDefinition({ + action, +}: { + action: TAction; +}): TActionDefinition { + return { + ...ACTIONS[action], + defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action], + }; +} + +export function getDefaultShortcuts(): KeybindingConfig { + const shortcuts: KeybindingConfig = {}; + + for (const [action, defaultShortcuts] of Object.entries( + ACTION_DEFAULT_SHORTCUTS, + ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) { + for (const shortcut of defaultShortcuts) { + shortcuts[shortcut] = action; + } + } + + return shortcuts; +} diff --git a/apps/web/src/actions/index.ts b/apps/web/src/actions/index.ts new file mode 100644 index 00000000..801a846a --- /dev/null +++ b/apps/web/src/actions/index.ts @@ -0,0 +1,3 @@ +export * from "./definitions"; +export * from "./types"; +export * from "./registry"; diff --git a/apps/web/src/actions/keybinding.ts b/apps/web/src/actions/keybinding.ts new file mode 100644 index 00000000..65ef4538 --- /dev/null +++ b/apps/web/src/actions/keybinding.ts @@ -0,0 +1,79 @@ +import type { TActionWithOptionalArgs } from "./types"; + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +export type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift"; + +export type Key = + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z" + | "0" + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9" + | "up" + | "down" + | "left" + | "right" + | "/" + | "?" + | "." + | "enter" + | "tab" + | "space" + | "escape" + | "esc" + | "backspace" + | "delete" + | "home" + | "end"; +/* eslint-enable */ + +export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; +// Singular keybindings (these will be disabled when an input-ish area has been focused) +export type SingleCharacterShortcutKey = `${Key}`; + +export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; + +export type KeybindingConfig = { + [key in ShortcutKey]?: TActionWithOptionalArgs; +}; diff --git a/apps/web/src/actions/keybindings-store.ts b/apps/web/src/actions/keybindings-store.ts new file mode 100644 index 00000000..c0a1a5e2 --- /dev/null +++ b/apps/web/src/actions/keybindings-store.ts @@ -0,0 +1,251 @@ +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { TActionWithOptionalArgs } from "@/actions"; +import { getDefaultShortcuts } from "@/actions"; +import { isTypableDOMElement } from "@/utils/browser"; +import { isAppleDevice } from "@/utils/platform"; +import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; +import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; + +const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); + +export interface KeybindingConflict { + key: ShortcutKey; + existingAction: TActionWithOptionalArgs; + newAction: TActionWithOptionalArgs; +} + +interface KeybindingsState { + keybindings: KeybindingConfig; + isCustomized: boolean; + overlayDepth: number; + openOverlayIds: string[]; + isLoadingProject: boolean; + isRecording: boolean; + + updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; + removeKeybinding: (key: ShortcutKey) => void; + resetToDefaults: () => void; + importKeybindings: (config: KeybindingConfig) => void; + exportKeybindings: () => KeybindingConfig; + openOverlay: (overlayId: string) => void; + closeOverlay: (overlayId: string) => void; + setLoadingProject: (loading: boolean) => void; + setIsRecording: (isRecording: boolean) => void; + validateKeybinding: ( + key: ShortcutKey, + action: TActionWithOptionalArgs, + ) => KeybindingConflict | null; + getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; + getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; +} + +function isDOMElement(element: EventTarget | null): element is HTMLElement { + return element instanceof HTMLElement; +} + +export const useKeybindingsStore = create()( + persist( + (set, get) => ({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + overlayDepth: 0, + openOverlayIds: [], + isLoadingProject: false, + isRecording: false, + + openOverlay: (overlayId) => + set((s) => { + const openOverlayIds = s.openOverlayIds.includes(overlayId) + ? s.openOverlayIds + : [...s.openOverlayIds, overlayId]; + const nextOverlayDepth = openOverlayIds.length; + return { + openOverlayIds, + overlayDepth: nextOverlayDepth, + }; + }), + closeOverlay: (overlayId) => + set((s) => { + const openOverlayIds = s.openOverlayIds.filter( + (id) => id !== overlayId, + ); + const nextOverlayDepth = openOverlayIds.length; + return { + openOverlayIds, + overlayDepth: nextOverlayDepth, + }; + }), + setLoadingProject: (loading) => { + set({ isLoadingProject: loading }); + }, + + updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + newKeybindings[key] = action; + + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, + + removeKeybinding: (key: ShortcutKey) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + delete newKeybindings[key]; + + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, + + resetToDefaults: () => { + set({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + }); + }, + + importKeybindings: (config: KeybindingConfig) => { + for (const [key] of Object.entries(config)) { + if (typeof key !== "string" || key.length === 0) { + throw new Error(`Invalid key format: ${key}`); + } + } + set({ + keybindings: { ...config }, + isCustomized: true, + }); + }, + + exportKeybindings: () => { + return get().keybindings; + }, + + validateKeybinding: ( + key: ShortcutKey, + action: TActionWithOptionalArgs, + ) => { + const { keybindings } = get(); + const existingAction = keybindings[key]; + + if (existingAction && existingAction !== action) { + return { + key, + existingAction, + newAction: action, + }; + } + + return null; + }, + setIsRecording: (isRecording: boolean) => { + set({ isRecording }); + }, + + getKeybindingsForAction: (action: TActionWithOptionalArgs) => { + const { keybindings } = get(); + return Object.keys(keybindings).filter( + (key) => keybindings[key as ShortcutKey] === action, + ) as ShortcutKey[]; + }, + + getKeybindingString: (ev: KeyboardEvent) => { + return generateKeybindingString(ev) as ShortcutKey | null; + }, + }), + { + name: "opencut-keybindings", + version: CURRENT_VERSION, + partialize: (state) => ({ + keybindings: state.keybindings, + isCustomized: state.isCustomized, + }), + migrate: (persisted, version) => + runMigrations({ state: persisted, fromVersion: version }), + }, + ), +); + +function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { + const target = ev.target; + const modifierKey = getActiveModifier(ev); + const key = getPressedKey(ev); + if (!key) return null; + + if (modifierKey) { + if ( + modifierKey === "shift" && + isDOMElement(target) && + isTypableDOMElement({ element: target as HTMLElement }) + ) { + return null; + } + + return `${modifierKey}+${key}` as ShortcutKey; + } + + if ( + isDOMElement(target) && + isTypableDOMElement({ element: target as HTMLElement }) + ) + return null; + + return `${key}` as ShortcutKey; +} + +function getPressedKey(ev: KeyboardEvent): string | null { + const key = (ev.key ?? "").toLowerCase(); + const code = ev.code ?? ""; + + if (code === "Space" || key === " " || key === "spacebar" || key === "space") + return "space"; + + if (key.startsWith("arrow")) return key.slice(5); + + if (key === "escape") return "escape"; + if (key === "tab") return "tab"; + if (key === "home") return "home"; + if (key === "end") return "end"; + if (key === "delete") return "delete"; + if (key === "backspace") return "backspace"; + + if (code.startsWith("Key")) { + const letter = code.slice(3).toLowerCase(); + if (letter.length === 1 && letter >= "a" && letter <= "z") return letter; + } + + // Use physical key position for AZERTY and other non-QWERTY layouts + if (code.startsWith("Digit")) { + const digit = code.slice(5); + if (digit.length === 1 && digit >= "0" && digit <= "9") return digit; + } + + const isDigit = key.length === 1 && key >= "0" && key <= "9"; + if (isDigit) return key; + + if (key === "/" || key === "." || key === "enter") return key; + + return null; +} + +function getActiveModifier(ev: KeyboardEvent): string | null { + const modifierKeys = { + ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, + alt: ev.altKey, + shift: ev.shiftKey, + }; + + const activeModifier = Object.keys(modifierKeys) + .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) + .join("+"); + + return activeModifier === "" ? null : activeModifier; +} diff --git a/apps/web/src/actions/keybindings/migrations/index.ts b/apps/web/src/actions/keybindings/migrations/index.ts new file mode 100644 index 00000000..71fd81a6 --- /dev/null +++ b/apps/web/src/actions/keybindings/migrations/index.ts @@ -0,0 +1,32 @@ +import { v2ToV3 } from "./v2-to-v3"; +import { v3ToV4 } from "./v3-to-v4"; +import { v4ToV5 } from "./v4-to-v5"; +import { v5ToV6 } from "./v5-to-v6"; +import { v6ToV7 } from "./v6-to-v7"; + +type MigrationFn = ({ state }: { state: unknown }) => unknown; + +const migrations: Record = { + 2: v2ToV3, + 3: v3ToV4, + 4: v4ToV5, + 5: v5ToV6, + 6: v6ToV7, +}; + +export const CURRENT_VERSION = 7; + +export function runMigrations({ + state, + fromVersion, +}: { + state: unknown; + fromVersion: number; +}): unknown { + let current = state; + for (let version = fromVersion; version < CURRENT_VERSION; version++) { + const migrate = migrations[version]; + if (migrate) current = migrate({ state: current }); + } + return current; +} diff --git a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts new file mode 100644 index 00000000..0171c5a5 --- /dev/null +++ b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts @@ -0,0 +1,26 @@ +import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; +import type { TActionWithOptionalArgs } from "@/actions"; + +interface V2State { + keybindings: KeybindingConfig; + isCustomized: boolean; +} + +export function v2ToV3({ state }: { state: unknown }): unknown { + const v2 = state as V2State; + + const renames: Record = { + "split-selected": "split", + "split-selected-left": "split-left", + "split-selected-right": "split-right", + }; + + const migrated = { ...v2.keybindings }; + for (const [key, action] of Object.entries(migrated)) { + if (action && renames[action]) { + migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; + } + } + + return { ...v2, keybindings: migrated }; +} diff --git a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts new file mode 100644 index 00000000..8541071b --- /dev/null +++ b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts @@ -0,0 +1,25 @@ +import type { TActionWithOptionalArgs } from "@/actions"; +import type { ShortcutKey } from "@/actions/keybinding"; +import type { KeybindingConfig } from "@/actions/keybinding"; + +interface V3State { + keybindings: KeybindingConfig; + isCustomized: boolean; +} + +export function v3ToV4({ state }: { state: unknown }): unknown { + const v3 = state as V3State; + + const renames: Record = { + "paste-selected": "paste-copied", + }; + + const migrated = { ...v3.keybindings }; + for (const [key, action] of Object.entries(migrated)) { + if (action && renames[action]) { + migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; + } + } + + return { ...v3, keybindings: migrated }; +} diff --git a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts new file mode 100644 index 00000000..f6937c39 --- /dev/null +++ b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts @@ -0,0 +1,17 @@ +import type { KeybindingConfig } from "@/actions/keybinding"; + +interface V4State { + keybindings: KeybindingConfig; + isCustomized: boolean; +} + +export function v4ToV5({ state }: { state: unknown }): unknown { + const v4 = state as V4State; + const keybindings = { ...v4.keybindings }; + + if (!keybindings.escape) { + keybindings.escape = "deselect-all"; + } + + return { ...v4, keybindings }; +} diff --git a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts new file mode 100644 index 00000000..09b44307 --- /dev/null +++ b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts @@ -0,0 +1,17 @@ +import type { KeybindingConfig } from "@/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/actions/keybindings/migrations/v6-to-v7.ts b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts new file mode 100644 index 00000000..46a99ac1 --- /dev/null +++ b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts @@ -0,0 +1,19 @@ +import type { KeybindingConfig } from "@/actions/keybinding"; + +interface V6State { + keybindings: KeybindingConfig; + isCustomized: boolean; +} + +export function v6ToV7({ state }: { state: unknown }): unknown { + const v6 = state as V6State; + const keybindings = { ...v6.keybindings }; + + for (const key of Object.keys(keybindings) as Array) { + if (keybindings[key] === ("split-element" as never)) { + keybindings[key] = "split"; + } + } + + return { ...v6, keybindings }; +} diff --git a/apps/web/src/actions/registry.ts b/apps/web/src/actions/registry.ts new file mode 100644 index 00000000..e69b3b9f --- /dev/null +++ b/apps/web/src/actions/registry.ts @@ -0,0 +1,63 @@ +import type { + TAction, + TActionFunc, + TActionWithArgs, + TActionWithOptionalArgs, + TActionArgsMap, + TArgOfAction, + TInvocationTrigger, +} from "./types"; + +type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void; +const boundActions: Partial> = {}; + +export function bindAction( + action: A, + handler: TActionFunc, +) { + const handlers = boundActions[action]; + const typedHandler = handler as ActionHandler; + if (handlers) { + handlers.push(typedHandler); + } else { + boundActions[action] = [typedHandler]; + } +} + +export function unbindAction( + action: A, + handler: TActionFunc, +) { + const handlers = boundActions[action]; + if (!handlers) return; + + const typedHandler = handler as ActionHandler; + boundActions[action] = handlers.filter((h) => h !== typedHandler); + + if (boundActions[action]?.length === 0) { + delete boundActions[action]; + } +} + +type InvokeActionFunc = { + ( + action: TActionWithOptionalArgs, + args?: undefined, + trigger?: TInvocationTrigger, + ): void; + ( + action: A, + args: TActionArgsMap[A], + trigger?: TInvocationTrigger, + ): void; +}; + +export const invokeAction: InvokeActionFunc = ( + action: A, + args?: TArgOfAction, + trigger?: TInvocationTrigger, +) => { + boundActions[action]?.forEach((handler) => { + handler(args, trigger); + }); +}; diff --git a/apps/web/src/actions/types.ts b/apps/web/src/actions/types.ts new file mode 100644 index 00000000..41258c7b --- /dev/null +++ b/apps/web/src/actions/types.ts @@ -0,0 +1,44 @@ +import type { MutableRefObject } from "react"; +import type { TAction } from "./definitions"; + +export type { TAction }; + +export type TActionArgsMap = { + "seek-forward": { seconds: number } | undefined; + "seek-backward": { seconds: number } | undefined; + "jump-forward": { seconds: number } | undefined; + "jump-backward": { seconds: number } | undefined; + "remove-media-asset": { projectId: string; assetId: string }; + "remove-media-assets": { projectId: string; assetIds: string[] }; +}; + +type TKeysWithValueUndefined = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +export type TActionWithArgs = keyof TActionArgsMap; + +export type TActionWithOptionalArgs = + | TActionWithNoArgs + | TKeysWithValueUndefined; + +export type TActionWithNoArgs = Exclude; + +export type TArgOfAction = A extends TActionWithArgs + ? TActionArgsMap[A] + : undefined; + +export type TActionFunc = A extends TActionWithArgs + ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void + : (_?: undefined, trigger?: TInvocationTrigger) => void; + +export type TInvocationTrigger = "keypress" | "mouseclick"; + +export type TBoundActionList = { + [A in TAction]?: Array>; +}; + +export type TActionHandlerOptions = + | MutableRefObject + | boolean + | undefined; diff --git a/apps/web/src/actions/use-action-handler.ts b/apps/web/src/actions/use-action-handler.ts new file mode 100644 index 00000000..a8bb0054 --- /dev/null +++ b/apps/web/src/actions/use-action-handler.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { + TAction, + TActionFunc, + TActionHandlerOptions, + TArgOfAction, + TInvocationTrigger, +} from "@/actions"; +import { bindAction, unbindAction } from "@/actions"; + +export function useActionHandler( + action: A, + handler: TActionFunc, + isActive: TActionHandlerOptions, +) { + const handlerRef = useRef>(handler); + const isBoundRef = useRef(false); + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + const stableHandler = useCallback( + (...parameters: [TArgOfAction, TInvocationTrigger?]) => { + ( + handlerRef.current as ( + ...handlerParameters: [TArgOfAction, TInvocationTrigger?] + ) => void + )(...parameters); + }, + [], + ) as TActionFunc; + + useEffect(() => { + const shouldBind = + isActive === undefined || + (typeof isActive === "boolean" ? isActive : isActive.current); + + if (shouldBind && !isBoundRef.current) { + bindAction(action, stableHandler); + isBoundRef.current = true; + } else if (!shouldBind && isBoundRef.current) { + unbindAction(action, stableHandler); + isBoundRef.current = false; + } + + return () => { + unbindAction(action, stableHandler); + isBoundRef.current = false; + }; + }, [action, stableHandler, isActive]); +} diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts new file mode 100644 index 00000000..e62a8ce0 --- /dev/null +++ b/apps/web/src/actions/use-editor-actions.ts @@ -0,0 +1,488 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useTimelineStore } from "@/timeline/timeline-store"; +import { useActionHandler } from "@/actions/use-action-handler"; +import { useEditor } from "@/editor/use-editor"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; +import { getElementsAtTime, hasMediaId } from "@/timeline"; +import { cancelInteraction } from "@/editor/cancel-interaction"; +import { invokeAction } from "@/actions"; +import { canToggleSourceAudio } from "@/timeline/audio-separation"; +import { + activateScope, + clearActiveScope, + type ScopeEntry, +} from "@/selection/scope"; + +export function useEditorActions() { + const editor = useEditor(); + const { selectedElements, setElementSelection } = useElementSelection(); + const { selectedKeyframes, clearKeyframeSelection } = useKeyframeSelection(); + const selectedMaskPointSelection = useEditor((e) => + e.selection.getSelectedMaskPointSelection(), + ); + 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 clearTimelineActiveSelectionRef = useRef(() => {}); + const timelineScopeRef = useRef(null); + const hasTimelineSelection = + selectedElements.length > 0 || + selectedKeyframes.length > 0 || + selectedMaskPointSelection !== null; + + hasTimelineSelectionRef.current = hasTimelineSelection; + clearTimelineSelectionRef.current = () => { + editor.selection.clearSelection(); + }; + clearTimelineActiveSelectionRef.current = () => { + editor.selection.clearMostSpecificSelection(); + }; + + if (!timelineScopeRef.current) { + timelineScopeRef.current = { + hasSelection: () => hasTimelineSelectionRef.current, + clear: () => { + clearTimelineSelectionRef.current(); + }, + clearActive: () => { + clearTimelineActiveSelectionRef.current(); + }, + }; + } + + useEffect(() => { + if (!hasTimelineSelection) { + return; + } + + const timelineScope = timelineScopeRef.current; + if (!timelineScope) { + return; + } + + return activateScope({ entry: timelineScope }); + }, [hasTimelineSelection]); + + useActionHandler( + "toggle-play", + () => { + editor.playback.toggle(); + }, + undefined, + ); + + useActionHandler( + "stop-playback", + () => { + if (editor.playback.getIsPlaying()) { + editor.playback.toggle(); + } + editor.playback.seek({ time: 0 }); + }, + undefined, + ); + + useActionHandler( + "seek-forward", + (args) => { + const seconds = args?.seconds ?? 1; + editor.playback.seek({ + time: Math.min( + editor.timeline.getTotalDuration(), + editor.playback.getCurrentTime() + seconds, + ), + }); + }, + undefined, + ); + + useActionHandler( + "seek-backward", + (args) => { + const seconds = args?.seconds ?? 1; + editor.playback.seek({ + time: Math.max(0, editor.playback.getCurrentTime() - seconds), + }); + }, + undefined, + ); + + useActionHandler( + "frame-step-forward", + () => { + const fps = editor.project.getActive().settings.fps; + const ticksPerFrame = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); + editor.playback.seek({ + time: Math.min( + editor.timeline.getTotalDuration(), + editor.playback.getCurrentTime() + ticksPerFrame, + ), + }); + }, + undefined, + ); + + useActionHandler( + "frame-step-backward", + () => { + const fps = editor.project.getActive().settings.fps; + const ticksPerFrame = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); + editor.playback.seek({ + time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame), + }); + }, + undefined, + ); + + useActionHandler( + "jump-forward", + (args) => { + const seconds = args?.seconds ?? 5; + editor.playback.seek({ + time: Math.min( + editor.timeline.getTotalDuration(), + editor.playback.getCurrentTime() + seconds, + ), + }); + }, + undefined, + ); + + useActionHandler( + "jump-backward", + (args) => { + const seconds = args?.seconds ?? 5; + editor.playback.seek({ + time: Math.max(0, editor.playback.getCurrentTime() - seconds), + }); + }, + undefined, + ); + + useActionHandler( + "goto-start", + () => { + editor.playback.seek({ time: 0 }); + }, + undefined, + ); + + useActionHandler( + "goto-end", + () => { + editor.playback.seek({ time: editor.timeline.getTotalDuration() }); + }, + undefined, + ); + + useActionHandler( + "split", + () => { + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; + const elementsToSplit = + selectedElements.length > 0 + ? selectedElements + : getElementsAtTime({ + tracks, + time: currentTime, + }); + + if (elementsToSplit.length === 0) return; + + editor.timeline.splitElements({ + elements: elementsToSplit, + splitTime: currentTime, + }); + }, + undefined, + ); + + useActionHandler( + "split-left", + () => { + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; + const elementsToSplit = + selectedElements.length > 0 + ? selectedElements + : getElementsAtTime({ + tracks, + time: currentTime, + }); + + if (elementsToSplit.length === 0) return; + + const rightSideElements = editor.timeline.splitElements({ + elements: elementsToSplit, + splitTime: currentTime, + retainSide: "right", + }); + + if (rippleEditingEnabled && rightSideElements.length > 0) { + const firstRightElement = editor.timeline.getElementsWithTracks({ + elements: [rightSideElements[0]], + })[0]; + if (firstRightElement) { + editor.playback.seek({ time: firstRightElement.element.startTime }); + } + } + }, + undefined, + ); + + useActionHandler( + "split-right", + () => { + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; + const elementsToSplit = + selectedElements.length > 0 + ? selectedElements + : getElementsAtTime({ + tracks, + time: currentTime, + }); + + if (elementsToSplit.length === 0) return; + + editor.timeline.splitElements({ + elements: elementsToSplit, + splitTime: currentTime, + retainSide: "left", + }); + }, + undefined, + ); + + useActionHandler( + "delete-selected", + () => { + switch (editor.selection.getActiveSelectionKind()) { + case "mask-points": + if (!selectedMaskPointSelection) { + return; + } + editor.timeline.deleteCustomMaskPoints({ + trackId: selectedMaskPointSelection.trackId, + elementId: selectedMaskPointSelection.elementId, + maskId: selectedMaskPointSelection.maskId, + pointIds: selectedMaskPointSelection.pointIds, + }); + return; + case "keyframes": + if (selectedKeyframes.length === 0) { + return; + } + editor.timeline.removeKeyframes({ keyframes: selectedKeyframes }); + clearKeyframeSelection(); + return; + case "elements": + if (selectedElements.length === 0) { + return; + } + editor.timeline.deleteElements({ + elements: selectedElements, + }); + return; + default: + return; + } + }, + undefined, + ); + + useActionHandler( + "toggle-source-audio", + () => { + if (selectedElements.length !== 1) { + return; + } + + const selectedElement = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + })[0]; + if (!selectedElement) { + return; + } + + const mediaAsset = (() => { + const { element } = selectedElement; + if (!hasMediaId(element)) { + return null; + } + + return ( + editor.media + .getAssets() + .find((asset) => asset.id === element.mediaId) ?? null + ); + })(); + if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) { + return; + } + + editor.timeline.toggleSourceAudioSeparation({ + trackId: selectedElement.track.id, + elementId: selectedElement.element.id, + }); + }, + undefined, + ); + + useActionHandler( + "select-all", + () => { + const scene = editor.scenes.getActiveScene(); + const allElements = [ + ...scene.tracks.overlay, + scene.tracks.main, + ...scene.tracks.audio, + ].flatMap((track) => + track.elements.map((element) => ({ + trackId: track.id, + elementId: element.id, + })), + ); + setElementSelection({ elements: allElements }); + }, + undefined, + ); + + useActionHandler( + "cancel-interaction", + () => { + if (!cancelInteraction()) { + invokeAction("deselect-all"); + } + }, + undefined, + ); + + useActionHandler( + "deselect-all", + () => { + if (!clearActiveScope()) { + editor.selection.clearMostSpecificSelection(); + } + }, + undefined, + ); + + useActionHandler( + "duplicate-selected", + () => { + editor.timeline.duplicateElements({ + elements: selectedElements, + }); + }, + undefined, + ); + + useActionHandler( + "toggle-elements-muted-selected", + () => { + editor.timeline.toggleElementsMuted({ elements: selectedElements }); + }, + undefined, + ); + + useActionHandler( + "toggle-elements-visibility-selected", + () => { + editor.timeline.toggleElementsVisibility({ elements: selectedElements }); + }, + undefined, + ); + + useActionHandler( + "toggle-bookmark", + () => { + editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() }); + }, + undefined, + ); + + useActionHandler( + "copy-selected", + () => { + editor.clipboard.copy(); + }, + undefined, + ); + + useActionHandler( + "paste-copied", + () => { + editor.clipboard.paste(); + }, + undefined, + ); + + useActionHandler( + "toggle-snapping", + () => { + toggleSnapping(); + }, + undefined, + ); + + useActionHandler( + "toggle-ripple-editing", + () => { + toggleRippleEditing(); + }, + undefined, + ); + + useActionHandler( + "undo", + () => { + editor.command.undo(); + }, + undefined, + ); + + useActionHandler( + "redo", + () => { + editor.command.redo(); + }, + 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/actions/use-keybindings.ts b/apps/web/src/actions/use-keybindings.ts new file mode 100644 index 00000000..c129795c --- /dev/null +++ b/apps/web/src/actions/use-keybindings.ts @@ -0,0 +1,87 @@ +import { useEffect } from "react"; +import { invokeAction } from "@/actions"; +import { useEditor } from "@/editor/use-editor"; +import { useKeybindingsStore } from "@/actions/keybindings-store"; +import { isTypableDOMElement } from "@/utils/browser"; + +/** + * a composable that hooks to the caller component's + * lifecycle and hooks to the keyboard events to fire + * the appropriate actions based on keybindings + */ +export function useKeybindingsListener() { + const editor = useEditor(); + const { + keybindings, + getKeybindingString, + overlayDepth, + isLoadingProject, + isRecording, + } = useKeybindingsStore(); + + useEffect(() => { + const eventOptions: AddEventListenerOptions = { capture: true }; + const handleKeyDown = (ev: KeyboardEvent) => { + const normalizedKey = (ev.key ?? "").toLowerCase(); + + if (overlayDepth > 0 || isLoadingProject || isRecording) { + return; + } + + const binding = getKeybindingString(ev); + const activeElement = document.activeElement; + const isTextInput = + activeElement instanceof HTMLElement && + isTypableDOMElement({ element: activeElement }); + const boundAction = binding ? keybindings[binding] : undefined; + + if (normalizedKey === "escape" && isTextInput) { + activeElement.blur(); + return; + } + + if (!binding) return; + if (!boundAction) return; + + if (isTextInput) return; + if (boundAction === "paste-copied") { + if (!editor.clipboard.hasEntry()) return; + ev.preventDefault(); + invokeAction("paste-copied", undefined, "keypress"); + return; + } + + ev.preventDefault(); + + switch (boundAction) { + case "seek-forward": + invokeAction("seek-forward", { seconds: 1 }, "keypress"); + break; + case "seek-backward": + invokeAction("seek-backward", { seconds: 1 }, "keypress"); + break; + case "jump-forward": + invokeAction("jump-forward", { seconds: 5 }, "keypress"); + break; + case "jump-backward": + invokeAction("jump-backward", { seconds: 5 }, "keypress"); + break; + default: + invokeAction(boundAction, undefined, "keypress"); + } + }; + + document.addEventListener("keydown", handleKeyDown, eventOptions); + + return () => { + document.removeEventListener("keydown", handleKeyDown, eventOptions); + }; + }, [ + keybindings, + getKeybindingString, + overlayDepth, + isLoadingProject, + isRecording, + editor, + ]); +} diff --git a/apps/web/src/actions/use-keyboard-shortcuts-help.ts b/apps/web/src/actions/use-keyboard-shortcuts-help.ts new file mode 100644 index 00000000..7681e664 --- /dev/null +++ b/apps/web/src/actions/use-keyboard-shortcuts-help.ts @@ -0,0 +1,83 @@ +"use client"; + +import { useMemo } from "react"; +import { useKeybindingsStore } from "@/actions/keybindings-store"; +import { ACTIONS, type TActionWithOptionalArgs } from "@/actions"; +import { + getPlatformAlternateKey, + getPlatformSpecialKey, +} from "@/utils/platform"; + +export interface KeyboardShortcut { + id: string; + keys: string[]; + description: string; + category: string; + action: TActionWithOptionalArgs; + icon?: React.ReactNode; +} + +function formatKey({ key }: { key: string }): string { + return key + .replace("ctrl", getPlatformSpecialKey()) + .replace("alt", getPlatformAlternateKey()) + .replace("shift", "Shift") + .replace("left", "←") + .replace("right", "→") + .replace("up", "↑") + .replace("down", "↓") + .replace("space", "Space") + .replace("home", "Home") + .replace("enter", "Enter") + .replace("end", "End") + .replace("delete", "Delete") + .replace("backspace", "Backspace") + .replace("-", "+"); +} + +export function useKeyboardShortcutsHelp() { + const { keybindings } = useKeybindingsStore(); + + const shortcuts = useMemo(() => { + const result: KeyboardShortcut[] = []; + const actionToKeys: Partial> = {}; + + for (const [key, action] of Object.entries(keybindings) as Array< + [string, TActionWithOptionalArgs | undefined] + >) { + if (action) { + if (!actionToKeys[action]) { + actionToKeys[action] = []; + } + actionToKeys[action].push(formatKey({ key })); + } + } + + for (const [action, keys] of Object.entries(actionToKeys) as Array< + [TActionWithOptionalArgs, string[]] + >) { + const actionDef = ACTIONS[action]; + if (!actionDef) { + continue; + } + result.push({ + id: action, + keys, + description: actionDef.description, + category: actionDef.category, + action, + }); + } + + return result.sort((a, b) => { + if (a.category !== b.category) { + return a.category.localeCompare(b.category); + } + return a.description.localeCompare(b.description); + }); + }, [keybindings]); + + return { + shortcuts, + }; +} diff --git a/apps/web/src/animation/__tests__/binding-values.test.ts b/apps/web/src/animation/__tests__/binding-values.test.ts new file mode 100644 index 00000000..a1f2010c --- /dev/null +++ b/apps/web/src/animation/__tests__/binding-values.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { + composeAnimationValue, + createAnimationBinding, +} from "@/animation/binding-values"; + +describe("binding values", () => { + test("formats composed animated colors as hex", () => { + const binding = createAnimationBinding({ + path: "color", + kind: "color", + }); + + expect( + composeAnimationValue({ + binding, + componentValues: { + r: 1, + g: 0, + b: 0, + a: 1, + }, + }), + ).toBe("#ff0000"); + }); +}); diff --git a/apps/web/src/animation/bezier.ts b/apps/web/src/animation/bezier.ts new file mode 100644 index 00000000..6f9a1d18 --- /dev/null +++ b/apps/web/src/animation/bezier.ts @@ -0,0 +1,90 @@ +import type { ScalarAnimationKey } from "@/animation/types"; + +const BEZIER_SOLVE_ITERATIONS = 20; + +export function getBezierPoint({ + progress, + p0, + p1, + p2, + p3, +}: { + progress: number; + p0: number; + p1: number; + p2: number; + p3: number; +}) { + const mt = 1 - progress; + return ( + mt * mt * mt * p0 + + 3 * mt * mt * progress * p1 + + 3 * mt * progress * progress * p2 + + progress * progress * progress * p3 + ); +} + +export function getDefaultRightHandle({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + const span = rightKey.time - leftKey.time; + const valueDelta = rightKey.value - leftKey.value; + return { + dt: span / 3, + dv: valueDelta / 3, + }; +} + +export function getDefaultLeftHandle({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + const span = rightKey.time - leftKey.time; + const valueDelta = rightKey.value - leftKey.value; + return { + dt: -span / 3, + dv: -valueDelta / 3, + }; +} + +export function solveBezierProgressForTime({ + time, + leftKey, + rightKey, +}: { + time: number; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + let lower = 0; + let upper = 1; + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + + for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) { + const mid = (lower + upper) / 2; + const estimate = getBezierPoint({ + progress: mid, + p0: leftKey.time, + p1: leftKey.time + rightHandle.dt, + p2: rightKey.time + leftHandle.dt, + p3: rightKey.time, + }); + if (estimate < time) { + lower = mid; + } else { + upper = mid; + } + } + + return (lower + upper) / 2; +} diff --git a/apps/web/src/animation/binding-values.ts b/apps/web/src/animation/binding-values.ts new file mode 100644 index 00000000..a46aa9a6 --- /dev/null +++ b/apps/web/src/animation/binding-values.ts @@ -0,0 +1,344 @@ +import { converter, formatHex, formatHex8, parse } from "culori"; +import type { + AnimationBindingComponent, + AnimationBindingOfKind, + AnimationBindingInstance, + AnimationBindingKind, + ColorAnimationBinding, + DiscreteAnimationBinding, + NumberAnimationBinding, + AnimationPath, + AnimationValue, + DiscreteValue, + Vector2AnimationBinding, + VectorValue, +} from "@/animation/types"; +import { clamp } from "@/utils/math"; + +interface LinearRgba { + r: number; + g: number; + b: number; + a: number; +} + +export type AnimationComponentValue = number | DiscreteValue; + +const toRgb = converter("rgb"); + +function srgbToLinear({ value }: { value: number }): number { + return value <= 0.04045 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); +} + +function linearToSrgb({ value }: { value: number }): number { + const clamped = clamp({ value, min: 0, max: 1 }); + return clamped <= 0.0031308 + ? clamped * 12.92 + : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isVectorValue(value: unknown): value is VectorValue { + return isRecord(value) && typeof value.x === "number" && typeof value.y === "number"; +} + +export type EasingMode = "independent" | "shared"; + +/** + * Declares how easing curves apply to a binding's components. + * "shared" means all components always use the same curve (e.g. color — you never + * want to ease R independently from G/B/A). "independent" means each component + * can have its own curve. + */ +export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode { + return kind === "color" ? "shared" : "independent"; +} + +export function getBindingComponentKeys({ + kind, +}: { + kind: AnimationBindingKind; +}): string[] { + if (kind === "vector2") { + return ["x", "y"]; + } + + if (kind === "color") { + return ["r", "g", "b", "a"]; + } + + return ["value"]; +} + +export function buildBindingChannelId({ + path, + componentKey, +}: { + path: AnimationPath; + componentKey: string; +}): string { + return `${path}:${componentKey}`; +} + +function createBindingComponent({ + path, + key, +}: { + path: AnimationPath; + key: TKey; +}): AnimationBindingComponent { + return { + key, + channelId: buildBindingChannelId({ path, componentKey: key }), + }; +} + +function cloneBindingComponents({ + components, +}: { + components: AnimationBindingComponent[]; +}): AnimationBindingComponent[] { + return components.map((component) => ({ ...component })); +} + +const animationBindingFactories = { + color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({ + path, + kind: "color", + colorSpace: "srgb-linear", + components: [ + createBindingComponent({ path, key: "r" }), + createBindingComponent({ path, key: "g" }), + createBindingComponent({ path, key: "b" }), + createBindingComponent({ path, key: "a" }), + ], + }), + vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({ + path, + kind: "vector2", + components: [ + createBindingComponent({ path, key: "x" }), + createBindingComponent({ path, key: "y" }), + ], + }), + number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({ + path, + kind: "number", + components: [createBindingComponent({ path, key: "value" })], + }), + discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({ + path, + kind: "discrete", + components: [createBindingComponent({ path, key: "value" })], + }), +} satisfies { + [K in AnimationBindingKind]: ({ + path, + }: { + path: AnimationPath; + }) => AnimationBindingOfKind; +}; + +export function createAnimationBinding({ + path, + kind, +}: { + path: AnimationPath; + kind: TKind; +}): AnimationBindingOfKind; +export function createAnimationBinding({ + path, + kind, +}: { + path: AnimationPath; + kind: AnimationBindingKind; +}): AnimationBindingInstance { + return animationBindingFactories[kind]({ path }); +} + +const animationBindingCloners = { + color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + vector2: ({ + binding, + }: { + binding: Vector2AnimationBinding; + }): Vector2AnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + number: ({ + binding, + }: { + binding: NumberAnimationBinding; + }): NumberAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + discrete: ({ + binding, + }: { + binding: DiscreteAnimationBinding; + }): DiscreteAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), +} satisfies { + [K in AnimationBindingKind]: ({ + binding, + }: { + binding: AnimationBindingOfKind; + }) => AnimationBindingOfKind; +}; + +export function cloneAnimationBinding({ + binding, +}: { + binding: AnimationBindingOfKind; +}): AnimationBindingOfKind; +export function cloneAnimationBinding({ + binding, +}: { + binding: AnimationBindingInstance; +}): AnimationBindingInstance { + switch (binding.kind) { + case "color": + return animationBindingCloners.color({ binding }); + case "vector2": + return animationBindingCloners.vector2({ binding }); + case "number": + return animationBindingCloners.number({ binding }); + case "discrete": + return animationBindingCloners.discrete({ binding }); + } +} + +export function parseColorToLinearRgba({ + color, +}: { + color: string; +}): LinearRgba | null { + const parsed = parse(color); + const rgb = parsed ? toRgb(parsed) : null; + if (!rgb) { + return null; + } + + return { + r: srgbToLinear({ value: rgb.r ?? 0 }), + g: srgbToLinear({ value: rgb.g ?? 0 }), + b: srgbToLinear({ value: rgb.b ?? 0 }), + a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }), + }; +} + +export function formatLinearRgba({ + color, +}: { + color: LinearRgba; +}): string { + const rgb = { + mode: "rgb", + r: linearToSrgb({ value: color.r }), + g: linearToSrgb({ value: color.g }), + b: linearToSrgb({ value: color.b }), + alpha: clamp({ value: color.a, min: 0, max: 1 }), + } as const; + return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); +} + +export function decomposeAnimationValue({ + kind, + value, +}: { + kind: AnimationBindingKind; + value: AnimationValue; +}): Record | null { + if (kind === "number") { + return typeof value === "number" ? { value } : null; + } + + if (kind === "vector2") { + return isVectorValue(value) ? { x: value.x, y: value.y } : null; + } + + if (kind === "color") { + if (typeof value !== "string") { + return null; + } + const parsed = parseColorToLinearRgba({ color: value }); + if (!parsed) { + return null; + } + return { + r: parsed.r, + g: parsed.g, + b: parsed.b, + a: parsed.a, + }; + } + + return typeof value === "string" || typeof value === "boolean" + ? { value } + : null; +} + +export function composeAnimationValue({ + binding, + componentValues, +}: { + binding: AnimationBindingInstance; + componentValues: Record; +}): AnimationValue | null { + if (binding.kind === "number") { + const value = componentValues.value; + return typeof value === "number" ? value : null; + } + + if (binding.kind === "vector2") { + const x = componentValues.x; + const y = componentValues.y; + return typeof x === "number" && typeof y === "number" ? { x, y } : null; + } + + if (binding.kind === "color") { + const r = componentValues.r; + const g = componentValues.g; + const b = componentValues.b; + const a = componentValues.a; + if ( + typeof r !== "number" || + typeof g !== "number" || + typeof b !== "number" || + typeof a !== "number" + ) { + return null; + } + return formatLinearRgba({ + color: { + r, + g, + b, + a, + }, + }); + } + + const value = componentValues.value; + return typeof value === "string" || typeof value === "boolean" ? value : null; +} diff --git a/apps/web/src/animation/curve-bridge.ts b/apps/web/src/animation/curve-bridge.ts new file mode 100644 index 00000000..af411dd1 --- /dev/null +++ b/apps/web/src/animation/curve-bridge.ts @@ -0,0 +1,95 @@ +import { + getDefaultLeftHandle, + getDefaultRightHandle, +} from "@/animation/bezier"; +import type { + CurveHandle, + NormalizedCubicBezier, + ScalarAnimationKey, +} from "@/animation/types"; + +const VALUE_EPSILON = 1e-6; + +function clamp01({ value }: { value: number }): number { + return Math.max(0, Math.min(1, value)); +} + +export function getNormalizedCubicBezierForScalarSegment({ + leftKey, + rightKey, + referenceSpanValue, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; + /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */ + referenceSpanValue?: number; +}): NormalizedCubicBezier | null { + const spanTime = rightKey.time - leftKey.time; + const spanValue = rightKey.value - leftKey.value; + const effectiveSpanValue = + Math.abs(spanValue) > VALUE_EPSILON + ? spanValue + : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON + ? referenceSpanValue + : null; + + if (spanTime === 0 || effectiveSpanValue === null) { + return null; + } + + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + + return [ + clamp01({ value: rightHandle.dt / spanTime }), + rightHandle.dv / effectiveSpanValue, + clamp01({ value: 1 + leftHandle.dt / spanTime }), + 1 + leftHandle.dv / effectiveSpanValue, + ]; +} + +export function getCurveHandlesForNormalizedCubicBezier({ + leftKey, + rightKey, + cubicBezier, + referenceSpanValue, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; + cubicBezier: NormalizedCubicBezier; + /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */ + referenceSpanValue?: number; +}): { + rightHandle: CurveHandle; + leftHandle: CurveHandle; +} | null { + const spanTime = rightKey.time - leftKey.time; + const spanValue = rightKey.value - leftKey.value; + const effectiveSpanValue = + Math.abs(spanValue) > VALUE_EPSILON + ? spanValue + : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON + ? referenceSpanValue + : null; + + if (spanTime === 0 || effectiveSpanValue === null) { + return null; + } + + const [rawX1, y1, rawX2, y2] = cubicBezier; + const x1 = clamp01({ value: rawX1 }); + const x2 = clamp01({ value: rawX2 }); + + return { + rightHandle: { + dt: spanTime * x1, + dv: effectiveSpanValue * y1, + }, + leftHandle: { + dt: spanTime * (x2 - 1), + dv: effectiveSpanValue * (y2 - 1), + }, + }; +} diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts new file mode 100644 index 00000000..c635bb82 --- /dev/null +++ b/apps/web/src/animation/effect-param-channel.ts @@ -0,0 +1,100 @@ +import type { ParamValues } from "@/params"; +import type { Effect } from "@/effects/types"; +import type { + ElementAnimations, + EffectParamPath, +} from "@/animation/types"; +import { removeElementKeyframe } from "./keyframes"; +import { resolveAnimationPathValueAtTime } from "./resolve"; + +export const EFFECT_PARAM_PATH_PREFIX = "effects."; +export const EFFECT_PARAM_PATH_SUFFIX = ".params."; + +export function buildEffectParamPath({ + effectId, + paramKey, +}: { + effectId: string; + paramKey: string; +}): EffectParamPath { + return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`; +} + +export function isEffectParamPath( + propertyPath: string, +): propertyPath is EffectParamPath { + return ( + propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) && + propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX) + ); +} + +export function parseEffectParamPath({ + propertyPath, +}: { + propertyPath: string; +}): { effectId: string; paramKey: string } | null { + if (!isEffectParamPath(propertyPath)) { + return null; + } + + const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length); + const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX); + if (separatorIndex <= 0) { + return null; + } + + const effectId = withoutPrefix.slice(0, separatorIndex); + const paramKey = withoutPrefix.slice( + separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length, + ); + if (!effectId || !paramKey) { + return null; + } + + return { effectId, paramKey }; +} + +export function resolveEffectParamsAtTime({ + effect, + animations, + localTime, +}: { + effect: Effect; + animations: ElementAnimations | undefined; + localTime: number; +}): ParamValues { + const resolved: ParamValues = {}; + + for (const [paramKey, staticValue] of Object.entries(effect.params)) { + const path = buildEffectParamPath({ effectId: effect.id, paramKey }); + resolved[paramKey] = animations?.bindings[path] + ? resolveAnimationPathValueAtTime({ + animations, + propertyPath: path, + localTime, + fallbackValue: staticValue, + }) + : staticValue; + } + + return resolved; +} + +export function removeEffectParamKeyframe({ + animations, + effectId, + paramKey, + keyframeId, +}: { + animations: ElementAnimations | undefined; + effectId: string; + paramKey: string; + keyframeId: string; +}): ElementAnimations | undefined { + return removeElementKeyframe({ + animations, + propertyPath: buildEffectParamPath({ effectId, paramKey }), + keyframeId, + }); +} diff --git a/apps/web/src/animation/graph-channels.ts b/apps/web/src/animation/graph-channels.ts new file mode 100644 index 00000000..d6482602 --- /dev/null +++ b/apps/web/src/animation/graph-channels.ts @@ -0,0 +1,99 @@ +import type { + AnimationBindingInstance, + AnimationPath, + ElementAnimations, + ScalarAnimationChannel, + ScalarGraphChannel, + ScalarGraphKeyframeContext, +} from "@/animation/types"; + +export interface EditableScalarChannels { + binding: AnimationBindingInstance; + channels: ScalarGraphChannel[]; +} + +function isScalarAnimationChannel( + channel: ElementAnimations["channels"][string], +): channel is ScalarAnimationChannel { + return channel?.kind === "scalar"; +} + +export function getEditableScalarChannels({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): EditableScalarChannels | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const channels = binding.components.flatMap((component) => { + const channel = animations?.channels[component.channelId]; + if (!isScalarAnimationChannel(channel)) { + return []; + } + + return [ + { + propertyPath, + componentKey: component.key, + channelId: component.channelId, + channel, + } satisfies ScalarGraphChannel, + ]; + }); + + return { binding, channels }; +} + +export function getEditableScalarChannel({ + animations, + propertyPath, + componentKey, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; +}): ScalarGraphChannel | null { + const result = getEditableScalarChannels({ animations, propertyPath }); + return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null; +} + +export function getScalarKeyframeContext({ + animations, + propertyPath, + componentKey, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; +}): ScalarGraphKeyframeContext | null { + const scalarChannel = getEditableScalarChannel({ + animations, + propertyPath, + componentKey, + }); + if (!scalarChannel) { + return null; + } + + const keyframeIndex = scalarChannel.channel.keys.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeIndex < 0) { + return null; + } + + return { + ...scalarChannel, + keyframe: scalarChannel.channel.keys[keyframeIndex], + keyframeIndex, + previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null, + nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null, + }; +} diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts new file mode 100644 index 00000000..ebf0f940 --- /dev/null +++ b/apps/web/src/animation/graphic-param-channel.ts @@ -0,0 +1,73 @@ +import type { + ElementAnimations, + GraphicParamPath, +} from "@/animation/types"; +import type { ParamValues } from "@/params"; +import { + getGraphicDefinition, + resolveGraphicParams, +} from "@/graphics"; +import { resolveAnimationPathValueAtTime } from "./resolve"; + +export const GRAPHIC_PARAM_PATH_PREFIX = "params."; + +export function buildGraphicParamPath({ + paramKey, +}: { + paramKey: string; +}): GraphicParamPath { + return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`; +} + +export function isGraphicParamPath( + propertyPath: string, +): propertyPath is GraphicParamPath { + return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX); +} + +export function parseGraphicParamPath({ + propertyPath, +}: { + propertyPath: string; +}): { paramKey: string } | null { + if (!isGraphicParamPath(propertyPath)) { + return null; + } + + const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length); + return paramKey.length > 0 ? { paramKey } : null; +} + +export function resolveGraphicParamsAtTime({ + element, + localTime, +}: { + element: { + definitionId: string; + params: ParamValues; + animations?: ElementAnimations; + }; + localTime: number; +}): ParamValues { + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const baseParams = resolveGraphicParams(definition, element.params); + const resolved: ParamValues = { ...baseParams }; + + for (const param of definition.params) { + const path = buildGraphicParamPath({ paramKey: param.key }); + if (!element.animations?.bindings[path]) { + continue; + } + + resolved[param.key] = resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath: path, + localTime: Math.max(0, localTime), + fallbackValue: baseParams[param.key] ?? param.default, + }); + } + + return resolved; +} diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts new file mode 100644 index 00000000..384f3d17 --- /dev/null +++ b/apps/web/src/animation/index.ts @@ -0,0 +1,96 @@ +export { + getChannelValueAtTime, + getDiscreteChannelValueAtTime, + getScalarChannelValueAtTime, + getScalarSegmentInterpolation, + normalizeChannel, +} from "./interpolation"; + +export { + clampAnimationsToDuration, + cloneAnimations, + getChannel, + removeElementKeyframe, + retimeElementKeyframe, + setBindingComponentChannel, + setChannel, + splitAnimationsAtTime, + updateScalarKeyframeCurve, + upsertElementKeyframe, + upsertPathKeyframe, +} from "./keyframes"; + +export { + getElementLocalTime, + resolveAnimationPathValueAtTime, + resolveColorAtTime, + resolveNumberAtTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "./resolve"; + +export { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getDefaultInterpolationForProperty, + getElementBaseValueForProperty, + isAnimationPropertyPath, + supportsAnimationProperty, + type AnimationPropertyDefinition, + type NumericSpec, + withElementBaseValueForProperty, +} from "./property-registry"; + +export { + getElementKeyframes, + getKeyframeById, + getKeyframeAtTime, + hasKeyframesForPath, +} from "./keyframe-query"; + +export { + type EditableScalarChannels, + getEditableScalarChannel, + getEditableScalarChannels, + getScalarKeyframeContext, +} from "./graph-channels"; + +export { + getCurveHandlesForNormalizedCubicBezier, + getNormalizedCubicBezierForScalarSegment, +} from "./curve-bridge"; + +export { + buildGraphicParamPath, + isGraphicParamPath, + parseGraphicParamPath, + resolveGraphicParamsAtTime, +} from "./graphic-param-channel"; + +export { + buildEffectParamPath, + isEffectParamPath, + parseEffectParamPath, + removeEffectParamKeyframe, + resolveEffectParamsAtTime, +} from "./effect-param-channel"; + +export { + isAnimationPath, + coerceAnimationValueForParam, + resolveAnimationTarget, + getParamValueKind, + getParamDefaultInterpolation, + type AnimationPathDescriptor, +} from "./target-resolver"; + +export { + getGroupKeyframesAtTime, + hasGroupKeyframeAtTime, + type GroupKeyframeRef, +} from "./property-groups"; + +export { + type EasingMode, + getEasingModeForKind, +} from "./binding-values"; diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts new file mode 100644 index 00000000..277bfbb7 --- /dev/null +++ b/apps/web/src/animation/interpolation.ts @@ -0,0 +1,375 @@ +import type { + AnimationChannel, + AnimationInterpolation, + AnimationValue, + DiscreteAnimationChannel, + DiscreteValue, + ScalarAnimationChannel, + ScalarAnimationKey, + ScalarSegmentType, +} from "@/animation/types"; +import { clamp } from "@/utils/math"; +import { + getBezierPoint, + getDefaultLeftHandle, + getDefaultRightHandle, + solveBezierProgressForTime, +} from "./bezier"; + +function byTimeAscending({ + leftTime, + rightTime, +}: { + leftTime: number; + rightTime: number; +}): number { + return leftTime - rightTime; +} + +function isWithinTimePair({ + time, + leftTime, + rightTime, +}: { + time: number; + leftTime: number; + rightTime: number; +}): boolean { + return time >= leftTime && time <= rightTime; +} + +function lerpNumber({ + leftValue, + rightValue, + progress, +}: { + leftValue: number; + rightValue: number; + progress: number; +}): number { + return leftValue + (rightValue - leftValue) * progress; +} + +function normalizeRightHandle({ + handle, + leftKey, + rightKey, +}: { + handle: ScalarAnimationKey["rightHandle"]; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + if (!handle) { + return undefined; + } + + const span = Math.max(1, rightKey.time - leftKey.time); + return { + dt: Math.min(span, Math.max(0, handle.dt)), + dv: handle.dv, + }; +} + +function normalizeLeftHandle({ + handle, + leftKey, + rightKey, +}: { + handle: ScalarAnimationKey["leftHandle"]; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + if (!handle) { + return undefined; + } + + const span = Math.max(1, rightKey.time - leftKey.time); + return { + dt: Math.max(-span, Math.min(0, handle.dt)), + dv: handle.dv, + }; +} + +function normalizeScalarKey({ + key, +}: { + key: ScalarAnimationKey; +}): ScalarAnimationKey { + return { + ...key, + tangentMode: key.tangentMode ?? "flat", + segmentToNext: key.segmentToNext ?? "linear", + }; +} + +function normalizeScalarChannel({ + channel, +}: { + channel: ScalarAnimationChannel; +}): ScalarAnimationChannel { + const sortedKeys = [...channel.keys] + .map((key) => normalizeScalarKey({ key })) + .sort((leftKey, rightKey) => + byTimeAscending({ + leftTime: leftKey.time, + rightTime: rightKey.time, + }), + ); + const nextKeys = sortedKeys.map((key, index) => { + const previousKey = sortedKeys[index - 1]; + const nextKey = sortedKeys[index + 1]; + return { + ...key, + leftHandle: + previousKey != null + ? normalizeLeftHandle({ + handle: key.leftHandle, + leftKey: previousKey, + rightKey: key, + }) + : undefined, + rightHandle: + nextKey != null + ? normalizeRightHandle({ + handle: key.rightHandle, + leftKey: key, + rightKey: nextKey, + }) + : undefined, + }; + }); + + return { + ...channel, + keys: nextKeys, + }; +} + +export function normalizeChannel({ + channel, +}: { + channel: TChannel; +}): TChannel { + if (channel.kind === "scalar") { + return normalizeScalarChannel({ + channel, + }) as TChannel; + } + + return { + ...channel, + keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => + byTimeAscending({ + leftTime: leftKeyframe.time, + rightTime: rightKeyframe.time, + }), + ), + } as TChannel; +} + +function extrapolateScalarEdge({ + mode, + edgeKey, + neighborKey, + time, +}: { + mode: "hold" | "linear"; + edgeKey: ScalarAnimationKey; + neighborKey: ScalarAnimationKey | undefined; + time: number; +}) { + if (mode === "hold" || !neighborKey) { + return edgeKey.value; + } + + const span = neighborKey.time - edgeKey.time; + if (span === 0) { + return edgeKey.value; + } + + return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value); +} + +export function getScalarSegmentInterpolation({ + segment, +}: { + segment: ScalarSegmentType; +}): AnimationInterpolation { + if (segment === "step") { + return "hold"; + } + + return segment === "bezier" ? "bezier" : "linear"; +} + +export function getScalarChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: ScalarAnimationChannel | undefined; + time: number; + fallbackValue: number; +}): number { + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + + const normalizedChannel = normalizeChannel({ + channel, + }); + const firstKey = normalizedChannel.keys[0]; + const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; + if (!firstKey || !lastKey) { + return fallbackValue; + } + + if (time <= firstKey.time) { + if (time < firstKey.time) { + return extrapolateScalarEdge({ + mode: normalizedChannel.extrapolation?.before ?? "hold", + edgeKey: firstKey, + neighborKey: normalizedChannel.keys[1], + time, + }); + } + + return firstKey.value; + } + + if (time >= lastKey.time) { + if (time > lastKey.time) { + return extrapolateScalarEdge({ + mode: normalizedChannel.extrapolation?.after ?? "hold", + edgeKey: lastKey, + neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2], + time, + }); + } + + return lastKey.value; + } + + for ( + let keyIndex = 0; + keyIndex < normalizedChannel.keys.length - 1; + keyIndex++ + ) { + const leftKey = normalizedChannel.keys[keyIndex]; + const rightKey = normalizedChannel.keys[keyIndex + 1]; + if (time === rightKey.time) { + return rightKey.value; + } + + if ( + !isWithinTimePair({ + time, + leftTime: leftKey.time, + rightTime: rightKey.time, + }) + ) { + continue; + } + + if (leftKey.segmentToNext === "step") { + return leftKey.value; + } + + const span = rightKey.time - leftKey.time; + if (span === 0) { + return rightKey.value; + } + + const progress = clamp({ + value: (time - leftKey.time) / span, + min: 0, + max: 1, + }); + if (leftKey.segmentToNext === "linear") { + return lerpNumber({ + leftValue: leftKey.value, + rightValue: rightKey.value, + progress, + }); + } + + const curveProgress = solveBezierProgressForTime({ + time, + leftKey, + rightKey, + }); + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + return getBezierPoint({ + progress: curveProgress, + p0: leftKey.value, + p1: leftKey.value + rightHandle.dv, + p2: rightKey.value + leftHandle.dv, + p3: rightKey.value, + }); + } + + return lastKey.value; +} + +export function getDiscreteChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + fallbackValue: DiscreteValue; +}): DiscreteValue { + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + + const normalizedChannel = normalizeChannel({ + channel, + }); + let currentValue = fallbackValue; + for (const key of normalizedChannel.keys) { + if (time < key.time) { + break; + } + currentValue = key.value; + } + return currentValue; +} + +export function getChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: AnimationChannel | undefined; + time: number; + fallbackValue: AnimationValue; +}): AnimationValue { + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + + if (channel.kind === "scalar") { + return typeof fallbackValue === "number" + ? getScalarChannelValueAtTime({ + channel, + time, + fallbackValue, + }) + : fallbackValue; + } + + if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { + return fallbackValue; + } + + return getDiscreteChannelValueAtTime({ + channel, + time, + fallbackValue, + }); +} diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts new file mode 100644 index 00000000..231aa5f2 --- /dev/null +++ b/apps/web/src/animation/keyframe-query.ts @@ -0,0 +1,294 @@ +import type { + AnimationBindingInstance, + AnimationChannel, + AnimationPath, + ElementAnimations, + ElementKeyframe, +} from "@/animation/types"; +import { + type AnimationComponentValue, + composeAnimationValue, +} from "./binding-values"; +import { + getChannelValueAtTime, + getScalarSegmentInterpolation, +} from "./interpolation"; +import { isAnimationPath } from "./target-resolver"; + +function getBindingFallbackValue({ + channel, +}: { + channel: ElementAnimations["channels"][string]; +}) { + if (!channel || channel.keys.length === 0) { + return channel?.kind === "discrete" ? false : 0; + } + + return channel.keys[0].value; +} + +interface BindingKeyframeMatch { + componentIndex: number; + channel: AnimationChannel; + keyframe: AnimationChannel["keys"][number]; +} + +function getBindingKeyframeMatches({ + animations, + binding, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; +}): BindingKeyframeMatch[] { + return binding.components.flatMap((component, componentIndex) => { + const channel = animations.channels[component.channelId]; + if (!channel || channel.keys.length === 0) { + return []; + } + + return channel.keys.map((keyframe) => ({ + componentIndex, + channel, + keyframe, + })); + }); +} + +function getUniqueBindingKeyframeMatches({ + animations, + binding, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; +}): BindingKeyframeMatch[] { + const sortedMatches = getBindingKeyframeMatches({ + animations, + binding, + }).sort( + (leftMatch, rightMatch) => + leftMatch.keyframe.time - rightMatch.keyframe.time || + leftMatch.componentIndex - rightMatch.componentIndex, + ); + const uniqueMatches: BindingKeyframeMatch[] = []; + + for (const match of sortedMatches) { + const previousMatch = uniqueMatches[uniqueMatches.length - 1]; + if ( + !previousMatch || + previousMatch.keyframe.time !== match.keyframe.time + ) { + uniqueMatches.push(match); + continue; + } + + if ( + previousMatch.componentIndex !== 0 && + match.componentIndex === 0 + ) { + uniqueMatches[uniqueMatches.length - 1] = match; + } + } + + return uniqueMatches; +} + +function getPreferredBindingKeyframeMatch({ + matches, +}: { + matches: BindingKeyframeMatch[]; +}): BindingKeyframeMatch | null { + return ( + matches.find((match) => match.componentIndex === 0) ?? + matches[0] ?? + null + ); +} + +function getComposedBindingValueAtTime({ + animations, + binding, + time, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; + time: number; +}) { + const componentValues = Object.fromEntries( + binding.components.map((component) => { + const channel = animations.channels[component.channelId]; + return [ + component.key, + getChannelValueAtTime({ + channel, + time, + fallbackValue: getBindingFallbackValue({ channel }), + }), + ]; + }), + ) as Record; + + return composeAnimationValue({ + binding, + componentValues, + }); +} + +function getKeyframeInterpolation({ + channel, + keyframe, +}: { + channel: AnimationChannel; + keyframe: AnimationChannel["keys"][number]; +}) { + return channel.kind === "scalar" && "segmentToNext" in keyframe + ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) + : "hold"; +} + +function toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; + propertyPath: AnimationPath; + keyframeMatch: BindingKeyframeMatch; +}): ElementKeyframe | null { + const value = getComposedBindingValueAtTime({ + animations, + binding, + time: keyframeMatch.keyframe.time, + }); + if (value === null) { + return null; + } + + return { + propertyPath, + id: keyframeMatch.keyframe.id, + time: keyframeMatch.keyframe.time, + value, + interpolation: getKeyframeInterpolation({ + channel: keyframeMatch.channel, + keyframe: keyframeMatch.keyframe, + }), + }; +} + +export function getElementKeyframes({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementKeyframe[] { + if (!animations) { + return []; + } + + return Object.entries(animations.bindings).flatMap( + ([propertyPath, binding]) => { + if (!binding || !isAnimationPath(propertyPath)) { + return []; + } + + return getUniqueBindingKeyframeMatches({ + animations, + binding, + }).flatMap((keyframeMatch) => { + const keyframe = toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); + if (!keyframe) { + return []; + } + + return [keyframe]; + }); + }, + ); +} + +export function hasKeyframesForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): boolean { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return false; + } + + return binding.components.some((component) => + Boolean(animations?.channels[component.channelId]?.keys.length), + ); +} + +export function getKeyframeAtTime({ + animations, + propertyPath, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + time: number; +}): ElementKeyframe | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const keyframeMatch = getPreferredBindingKeyframeMatch({ + matches: getBindingKeyframeMatches({ + animations, + binding, + }).filter(({ keyframe }) => keyframe.time === time), + }); + if (!keyframeMatch) { + return null; + } + + return toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); +} + +export function getKeyframeById({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + keyframeId: string; +}): ElementKeyframe | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const keyframeMatch = getPreferredBindingKeyframeMatch({ + matches: getBindingKeyframeMatches({ + animations, + binding, + }).filter(({ keyframe }) => keyframe.id === keyframeId), + }); + if (!keyframeMatch) { + return null; + } + + return toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); +} diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts new file mode 100644 index 00000000..12dc777d --- /dev/null +++ b/apps/web/src/animation/keyframes.ts @@ -0,0 +1,1338 @@ +import type { + AnimationBindingInstance, + AnimationBindingKind, + AnimationChannel, + AnimationInterpolation, + AnimationPath, + AnimationPropertyPath, + AnimationValue, + DiscreteAnimationChannel, + DiscreteAnimationKey, + ElementAnimations, + ScalarAnimationChannel, + ScalarAnimationKey, + ScalarCurveKeyframePatch, + ScalarSegmentType, +} from "@/animation/types"; +import { generateUUID } from "@/utils/id"; +import { + cloneAnimationBinding, + createAnimationBinding, + decomposeAnimationValue, +} from "./binding-values"; +import { + getBezierPoint, + getDefaultLeftHandle, + getDefaultRightHandle, + solveBezierProgressForTime, +} from "./bezier"; +import { + getChannelValueAtTime, + getScalarSegmentInterpolation, + normalizeChannel, +} from "./interpolation"; +import { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, +} from "./property-registry"; + +function isNearlySameTime({ + leftTime, + rightTime, +}: { + leftTime: number; + rightTime: number; +}): boolean { + return leftTime === rightTime; +} + +function hasChannelKeys({ + channel, +}: { + channel: AnimationChannel | undefined; +}): boolean { + return Boolean(channel && channel.keys.length > 0); +} + +function toAnimation({ + animations, +}: { + animations: ElementAnimations; +}): ElementAnimations | undefined { + const nextBindings = Object.fromEntries( + Object.entries(animations.bindings).filter(([, binding]) => binding), + ); + const nextChannels = Object.fromEntries( + Object.entries(animations.channels).filter(([, channel]) => + hasChannelKeys({ channel }), + ), + ); + if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) { + return undefined; + } + + return { + bindings: nextBindings, + channels: nextChannels, + }; +} + +function cloneAnimationsState({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementAnimations { + return { + bindings: { ...(animations?.bindings ?? {}) }, + channels: { ...(animations?.channels ?? {}) }, + }; +} + +function getBindingChannelKind({ + kind, +}: { + kind: AnimationBindingKind; +}): AnimationChannel["kind"] { + return kind === "discrete" ? "discrete" : "scalar"; +} + +function getPrimaryComponent({ + binding, +}: { + binding: AnimationBindingInstance; +}) { + return binding.components[0] ?? null; +} + +function getPrimaryChannelId({ + binding, +}: { + binding: AnimationBindingInstance; +}) { + return getPrimaryComponent({ binding })?.channelId ?? null; +} + +function getScalarSegmentType({ + interpolation, +}: { + interpolation: AnimationInterpolation; +}): ScalarSegmentType { + if (interpolation === "hold") { + return "step"; + } + return interpolation === "bezier" ? "bezier" : "linear"; +} + +function getInterpolationForBinding({ + kind, + interpolation, +}: { + kind: AnimationBindingKind; + interpolation: AnimationInterpolation | undefined; +}): AnimationInterpolation { + if (kind === "discrete") { + return "hold"; + } + + if ( + interpolation === "linear" || + interpolation === "hold" || + interpolation === "bezier" + ) { + return interpolation; + } + + return "linear"; +} + +function createEmptyChannelForBindingKind({ + kind, +}: { + kind: AnimationBindingKind; +}): AnimationChannel { + if (kind === "discrete") { + return { + kind: "discrete", + keys: [], + } satisfies DiscreteAnimationChannel; + } + + return { + kind: "scalar", + keys: [], + } satisfies ScalarAnimationChannel; +} + +function createScalarKey({ + id, + time, + value, + interpolation, + previousKey, +}: { + id: string; + time: number; + value: number; + interpolation?: AnimationInterpolation; + previousKey?: ScalarAnimationKey; +}): ScalarAnimationKey { + return { + id, + time, + value, + leftHandle: previousKey?.leftHandle, + rightHandle: previousKey?.rightHandle, + segmentToNext: + previousKey?.segmentToNext ?? + getScalarSegmentType({ interpolation: interpolation ?? "linear" }), + tangentMode: previousKey?.tangentMode ?? "flat", + }; +} + +function createDiscreteKey({ + id, + time, + value, +}: { + id: string; + time: number; + value: string | boolean; +}): DiscreteAnimationKey { + return { + id, + time, + value, + }; +} + +function getBinding({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): AnimationBindingInstance | undefined { + return animations?.bindings[propertyPath]; +} + +function getChannelById({ + animations, + channelId, +}: { + animations: ElementAnimations | undefined; + channelId: string; +}): AnimationChannel | undefined { + return animations?.channels[channelId]; +} + +function getBindingComponent({ + binding, + componentKey, +}: { + binding: AnimationBindingInstance; + componentKey: string; +}) { + return binding.components.find((component) => component.key === componentKey) ?? null; +} + +function getTargetKeyMetadata({ + channel, + time, + keyframeId, +}: { + channel: AnimationChannel | undefined; + time: number; + keyframeId?: string; +}) { + const normalizedChannel = + channel != null ? normalizeChannel({ channel }) : undefined; + const keys = normalizedChannel?.keys ?? []; + if (keyframeId) { + const keyById = keys.find((key) => key.id === keyframeId); + if (keyById) { + return { + id: keyById.id, + time, + }; + } + } + + const keyAtTime = keys.find((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (keyAtTime) { + return { + id: keyAtTime.id, + time: keyAtTime.time, + }; + } + + return { + id: keyframeId ?? generateUUID(), + time, + }; +} + +function upsertDiscreteChannelKey({ + channel, + time, + value, + keyframeId, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + value: string | boolean; + keyframeId?: string; +}): DiscreteAnimationChannel { + const normalizedChannel = normalizeChannel({ + channel: channel ?? { kind: "discrete", keys: [] }, + }); + const keys = [...normalizedChannel.keys]; + if (keyframeId) { + const existingIndex = keys.findIndex((key) => key.id === keyframeId); + if (existingIndex >= 0) { + keys[existingIndex] = createDiscreteKey({ + id: keys[existingIndex].id, + time, + value, + }); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); + } + } + + const existingAtTimeIndex = keys.findIndex((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (existingAtTimeIndex >= 0) { + keys[existingAtTimeIndex] = createDiscreteKey({ + id: keys[existingAtTimeIndex].id, + time: keys[existingAtTimeIndex].time, + value, + }); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); + } + + keys.push( + createDiscreteKey({ + id: keyframeId ?? generateUUID(), + time, + value, + }), + ); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); +} + +function upsertScalarChannelKey({ + channel, + time, + value, + interpolation, + defaultInterpolation, + keyframeId, +}: { + channel: ScalarAnimationChannel | undefined; + time: number; + value: number; + interpolation?: AnimationInterpolation; + defaultInterpolation?: AnimationInterpolation; + keyframeId?: string; +}): ScalarAnimationChannel { + const normalizedChannel = normalizeChannel({ + channel: channel ?? { kind: "scalar", keys: [] }, + }); + const keys = [...normalizedChannel.keys]; + if (keyframeId) { + const existingIndex = keys.findIndex((key) => key.id === keyframeId); + if (existingIndex >= 0) { + keys[existingIndex] = createScalarKey({ + id: keys[existingIndex].id, + time, + value, + interpolation, + previousKey: + interpolation != null + ? { + ...keys[existingIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + } + : keys[existingIndex], + }); + return normalizeChannel({ + channel: { + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, + }); + } + } + + const existingAtTimeIndex = keys.findIndex((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (existingAtTimeIndex >= 0) { + keys[existingAtTimeIndex] = createScalarKey({ + id: keys[existingAtTimeIndex].id, + time: keys[existingAtTimeIndex].time, + value, + interpolation, + previousKey: + interpolation != null + ? { + ...keys[existingAtTimeIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + } + : keys[existingAtTimeIndex], + }); + return normalizeChannel({ + channel: { + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, + }); + } + + keys.push( + createScalarKey({ + id: keyframeId ?? generateUUID(), + time, + value, + interpolation: interpolation ?? defaultInterpolation, + }), + ); + return normalizeChannel({ + channel: { + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, + }); +} + +export function getChannel({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): AnimationChannel | undefined { + const binding = getBinding({ animations, propertyPath }); + const primaryChannelId = + binding != null ? getPrimaryChannelId({ binding }) : null; + return primaryChannelId ? animations?.channels[primaryChannelId] : undefined; +} + +export function upsertPathKeyframe({ + animations, + propertyPath, + time, + value, + interpolation, + keyframeId, + kind, + defaultInterpolation, + coerceValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; +}): ElementAnimations | undefined { + const coercedValue = coerceValue({ value }); + if (coercedValue === null) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + const existingBinding = getBinding({ + animations, + propertyPath, + }); + const binding = + existingBinding && existingBinding.kind === kind + ? cloneAnimationBinding({ binding: existingBinding }) + : createAnimationBinding({ path: propertyPath, kind }); + const primaryChannel = getChannel({ + animations, + propertyPath, + }); + const targetKey = getTargetKeyMetadata({ + channel: primaryChannel, + time, + keyframeId, + }); + const componentValues = decomposeAnimationValue({ + kind, + value: coercedValue, + }); + if (!componentValues) { + return animations; + } + + const explicitInterpolation = + interpolation != null + ? getInterpolationForBinding({ kind, interpolation }) + : undefined; + const validatedDefaultInterpolation = getInterpolationForBinding({ + kind, + interpolation: defaultInterpolation, + }); + nextAnimations.bindings[propertyPath] = binding; + for (const component of binding.components) { + const nextValue = componentValues[component.key]; + if (nextValue == null) { + continue; + } + + const currentChannel = getChannelById({ + animations, + channelId: component.channelId, + }); + const targetChannel = + currentChannel?.kind === getBindingChannelKind({ kind }) + ? currentChannel + : createEmptyChannelForBindingKind({ kind }); + nextAnimations.channels[component.channelId] = + targetChannel.kind === "discrete" + ? upsertDiscreteChannelKey({ + channel: targetChannel, + time: targetKey.time, + value: nextValue as string | boolean, + keyframeId: targetKey.id, + }) + : upsertScalarChannelKey({ + channel: targetChannel, + time: targetKey.time, + value: nextValue as number, + interpolation: explicitInterpolation, + defaultInterpolation: validatedDefaultInterpolation, + keyframeId: targetKey.id, + }); + } + + return toAnimation({ + animations: nextAnimations, + }); +} + +export function upsertElementKeyframe({ + animations, + propertyPath, + time, + value, + interpolation, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; +}): ElementAnimations | undefined { + const coercedValue = coerceAnimationValueForProperty({ + propertyPath, + value, + }); + if (coercedValue === null) { + return animations; + } + + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return upsertPathKeyframe({ + animations, + propertyPath, + time, + value: coercedValue, + interpolation, + keyframeId, + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + coerceValue: ({ value: nextValue }) => + coerceAnimationValueForProperty({ + propertyPath, + value: nextValue, + }), + }); +} + +export function upsertKeyframe({ + channel, + time, + value, + interpolation, + keyframeId, +}: { + channel: AnimationChannel | undefined; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; +}): AnimationChannel | undefined { + if (!channel) { + return undefined; + } + + if (channel.kind === "discrete") { + if (typeof value !== "string" && typeof value !== "boolean") { + return channel; + } + + return upsertDiscreteChannelKey({ + channel, + time, + value, + keyframeId, + }); + } + + if (typeof value !== "number") { + return channel; + } + + return upsertScalarChannelKey({ + channel, + time, + value, + interpolation, + keyframeId, + }); +} + +export function removeKeyframe({ + channel, + keyframeId, +}: { + channel: AnimationChannel | undefined; + keyframeId: string; +}): AnimationChannel | undefined { + if (!channel) { + return undefined; + } + + const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); + if (nextKeys.length === 0) { + return undefined; + } + + return normalizeChannel({ + channel: { + ...channel, + keys: nextKeys, + } as AnimationChannel, + }); +} + +export function retimeKeyframe({ + channel, + keyframeId, + time, +}: { + channel: AnimationChannel | undefined; + keyframeId: string; + time: number; +}): AnimationChannel | undefined { + if (!channel) { + return undefined; + } + + const keyframeByIdIndex = channel.keys.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeByIdIndex < 0) { + return channel; + } + + const nextKeys = [...channel.keys]; + nextKeys[keyframeByIdIndex] = { + ...nextKeys[keyframeByIdIndex], + time, + }; + + return normalizeChannel({ + channel: { + ...channel, + keys: nextKeys, + } as AnimationChannel, + }); +} + +export function setChannel({ + animations, + propertyPath, + channel, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + channel: AnimationChannel | undefined; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + if (binding.components.length !== 1) { + throw new Error( + `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, + ); + } + + const primaryComponent = getPrimaryComponent({ binding }); + if (!primaryComponent) { + return animations; + } + + return setBindingComponentChannel({ + animations, + propertyPath, + componentKey: primaryComponent.key, + channel, + }); +} + +export function setBindingComponentChannel({ + animations, + propertyPath, + componentKey, + channel, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + channel: AnimationChannel | undefined; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const component = getBindingComponent({ + binding, + componentKey, + }); + if (!component) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + if (!channel || !hasChannelKeys({ channel })) { + delete nextAnimations.channels[component.channelId]; + const hasRemainingKeys = binding.components.some((candidate) => + hasChannelKeys({ + channel: nextAnimations.channels[candidate.channelId], + }), + ); + if (!hasRemainingKeys) { + delete nextAnimations.bindings[propertyPath]; + } + return toAnimation({ + animations: nextAnimations, + }); + } + + nextAnimations.channels[component.channelId] = normalizeChannel({ + channel, + }); + return toAnimation({ + animations: nextAnimations, + }); +} + +export function updateScalarKeyframeCurve({ + animations, + propertyPath, + componentKey, + keyframeId, + patch, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; + patch: ScalarCurveKeyframePatch; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const component = getBindingComponent({ + binding, + componentKey, + }); + if (!component) { + return animations; + } + + const channel = getChannelById({ + animations, + channelId: component.channelId, + }); + if (channel?.kind !== "scalar") { + return animations; + } + + const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId); + if (keyframeIndex < 0) { + return animations; + } + + const nextKeys = [...channel.keys]; + const currentKey = nextKeys[keyframeIndex]; + nextKeys[keyframeIndex] = { + ...currentKey, + leftHandle: + patch.leftHandle === undefined + ? currentKey.leftHandle + : patch.leftHandle ?? undefined, + rightHandle: + patch.rightHandle === undefined + ? currentKey.rightHandle + : patch.rightHandle ?? undefined, + segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext, + tangentMode: patch.tangentMode ?? currentKey.tangentMode, + }; + + return setBindingComponentChannel({ + animations, + propertyPath, + componentKey, + channel: { + kind: "scalar", + keys: nextKeys, + extrapolation: channel.extrapolation, + }, + }); +} + +export function cloneAnimations({ + animations, + shouldRegenerateKeyframeIds = false, +}: { + animations: ElementAnimations | undefined; + shouldRegenerateKeyframeIds?: boolean; +}): ElementAnimations | undefined { + if (!animations) { + return undefined; + } + + const nextAnimations = cloneAnimationsState({ animations }); + nextAnimations.bindings = Object.fromEntries( + Object.entries(animations.bindings).map(([path, binding]) => [ + path, + binding ? cloneAnimationBinding({ binding }) : binding, + ]), + ); + nextAnimations.channels = {}; + + for (const binding of Object.values(nextAnimations.bindings)) { + if (!binding) { + continue; + } + + const primaryChannel = getChannelById({ + animations, + channelId: getPrimaryChannelId({ binding }) ?? "", + }); + const keyIdMap = new Map(); + if (primaryChannel) { + for (const key of primaryChannel.keys) { + keyIdMap.set( + key.id, + shouldRegenerateKeyframeIds ? generateUUID() : key.id, + ); + } + } + + for (const component of binding.components) { + const currentChannel = getChannelById({ + animations, + channelId: component.channelId, + }); + if (!currentChannel) { + continue; + } + + nextAnimations.channels[component.channelId] = normalizeChannel({ + channel: { + ...currentChannel, + keys: currentChannel.keys.map((key) => ({ + ...key, + id: keyIdMap.get(key.id) ?? key.id, + })), + } as AnimationChannel, + }); + } + } + + return toAnimation({ + animations: nextAnimations, + }); +} + +export function clampAnimationsToDuration({ + animations, + duration, +}: { + animations: ElementAnimations | undefined; + duration: number; +}): ElementAnimations | undefined { + if (!animations || duration <= 0) { + return undefined; + } + + return splitAnimationsAtTime({ + animations, + splitTime: duration, + shouldIncludeSplitBoundary: true, + }).leftAnimations; +} + +function lerpPoint({ + left, + right, + progress, +}: { + left: { x: number; y: number }; + right: { x: number; y: number }; + progress: number; +}) { + return { + x: left.x + (right.x - left.x) * progress, + y: left.y + (right.y - left.y) * progress, + }; +} + +function splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: DiscreteAnimationChannel | undefined; + splitTime: number; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + if (!channel || channel.keys.length === 0) { + return { + leftChannel: undefined, + rightChannel: undefined, + }; + } + + const normalizedChannel = normalizeChannel({ channel }); + let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); + let rightKeys = normalizedChannel.keys + .filter((key) => key.time >= splitTime) + .map((key) => ({ ...key, time: key.time - splitTime })); + + if (shouldIncludeSplitBoundary) { + const hasBoundaryOnLeft = leftKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), + ); + const hasBoundaryOnRight = rightKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: 0 }), + ); + const boundaryValue = getChannelValueAtTime({ + channel: normalizedChannel, + time: splitTime, + fallbackValue: normalizedChannel.keys[0].value, + }); + if (!hasBoundaryOnLeft) { + leftKeys = [ + ...leftKeys, + createDiscreteKey({ + id: leftBoundaryId, + time: splitTime, + value: boundaryValue as string | boolean, + }), + ]; + } + if (!hasBoundaryOnRight) { + rightKeys = [ + createDiscreteKey({ + id: rightBoundaryId, + time: 0, + value: boundaryValue as string | boolean, + }), + ...rightKeys, + ]; + } + } + + return { + leftChannel: leftKeys.length + ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } }) + : undefined, + }; +} + +function splitScalarChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: ScalarAnimationChannel | undefined; + splitTime: number; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + if (!channel || channel.keys.length === 0) { + return { + leftChannel: undefined, + rightChannel: undefined, + }; + } + + const normalizedChannel = normalizeChannel({ channel }); + let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); + let rightKeys = normalizedChannel.keys + .filter((key) => key.time >= splitTime) + .map((key) => ({ ...key, time: key.time - splitTime })); + + const hasBoundaryOnLeft = leftKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), + ); + const hasBoundaryOnRight = rightKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: 0 }), + ); + if (!shouldIncludeSplitBoundary || (hasBoundaryOnLeft && hasBoundaryOnRight)) { + return { + leftChannel: leftKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + }; + } + + for (let keyIndex = 0; keyIndex < normalizedChannel.keys.length - 1; keyIndex++) { + const leftKey = normalizedChannel.keys[keyIndex]; + const rightKey = normalizedChannel.keys[keyIndex + 1]; + if ( + !( + splitTime > leftKey.time && + splitTime < rightKey.time + ) + ) { + continue; + } + + const boundaryValue = getChannelValueAtTime({ + channel: normalizedChannel, + time: splitTime, + fallbackValue: leftKey.value, + }) as number; + + if (leftKey.segmentToNext === "bezier") { + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + const progress = solveBezierProgressForTime({ + time: splitTime, + leftKey, + rightKey, + }); + const p0 = { x: leftKey.time, y: leftKey.value }; + const p1 = { + x: leftKey.time + rightHandle.dt, + y: leftKey.value + rightHandle.dv, + }; + const p2 = { + x: rightKey.time + leftHandle.dt, + y: rightKey.value + leftHandle.dv, + }; + const p3 = { x: rightKey.time, y: rightKey.value }; + const q0 = lerpPoint({ left: p0, right: p1, progress }); + const q1 = lerpPoint({ left: p1, right: p2, progress }); + const q2 = lerpPoint({ left: p2, right: p3, progress }); + const r0 = lerpPoint({ left: q0, right: q1, progress }); + const r1 = lerpPoint({ left: q1, right: q2, progress }); + const splitPoint = lerpPoint({ left: r0, right: r1, progress }); + leftKeys = [ + ...normalizedChannel.keys.filter((key) => key.time < splitTime), + { + ...leftKey, + rightHandle: { + dt: q0.x - p0.x, + dv: q0.y - p0.y, + }, + }, + { + id: leftBoundaryId, + time: splitTime, + value: boundaryValue, + leftHandle: { + dt: r0.x - splitPoint.x, + dv: r0.y - splitPoint.y, + }, + segmentToNext: leftKey.segmentToNext, + tangentMode: leftKey.tangentMode, + }, + ]; + rightKeys = [ + { + id: rightBoundaryId, + time: 0, + value: boundaryValue, + rightHandle: { + dt: r1.x - splitPoint.x, + dv: r1.y - splitPoint.y, + }, + segmentToNext: "bezier", + tangentMode: leftKey.tangentMode, + }, + { + ...rightKey, + time: rightKey.time - splitTime, + leftHandle: { + dt: q2.x - p3.x, + dv: q2.y - p3.y, + }, + }, + ...normalizedChannel.keys + .filter((key) => key.time > rightKey.time) + .map((key) => ({ + ...key, + time: key.time - splitTime, + })), + ]; + } else { + leftKeys = [ + ...leftKeys, + createScalarKey({ + id: leftBoundaryId, + time: splitTime, + value: boundaryValue, + interpolation: "linear", + }), + ]; + rightKeys = [ + createScalarKey({ + id: rightBoundaryId, + time: 0, + value: boundaryValue, + interpolation: getScalarSegmentInterpolation({ + segment: leftKey.segmentToNext, + }), + }), + ...rightKeys, + ]; + } + + return { + leftChannel: normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }), + rightChannel: normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }), + }; + } + + return { + leftChannel: leftKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + }; +} + +export function splitAnimationsAtTime({ + animations, + splitTime, + shouldIncludeSplitBoundary = true, +}: { + animations: ElementAnimations | undefined; + splitTime: number; + shouldIncludeSplitBoundary?: boolean; +}): { + leftAnimations: ElementAnimations | undefined; + rightAnimations: ElementAnimations | undefined; +} { + if (!animations) { + return { leftAnimations: undefined, rightAnimations: undefined }; + } + + const leftAnimations = cloneAnimationsState({ animations: undefined }); + const rightAnimations = cloneAnimationsState({ animations: undefined }); + + for (const [propertyPath, binding] of Object.entries(animations.bindings)) { + if (!binding) { + continue; + } + + const leftBinding = cloneAnimationBinding({ binding }); + const rightBinding = cloneAnimationBinding({ binding }); + const leftBoundaryId = generateUUID(); + const rightBoundaryId = generateUUID(); + let hasLeftKeys = false; + let hasRightKeys = false; + + for (const component of binding.components) { + const channel = getChannelById({ + animations, + channelId: component.channelId, + }); + const splitResult = + channel?.kind === "discrete" + ? splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }) + : splitScalarChannelAtTime({ + channel: channel as ScalarAnimationChannel | undefined, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }); + if (splitResult.leftChannel) { + leftAnimations.channels[component.channelId] = splitResult.leftChannel; + hasLeftKeys = true; + } + if (splitResult.rightChannel) { + rightAnimations.channels[component.channelId] = splitResult.rightChannel; + hasRightKeys = true; + } + } + + if (hasLeftKeys) { + leftAnimations.bindings[propertyPath] = leftBinding; + } + if (hasRightKeys) { + rightAnimations.bindings[propertyPath] = rightBinding; + } + } + + return { + leftAnimations: toAnimation({ animations: leftAnimations }), + rightAnimations: toAnimation({ animations: rightAnimations }), + }; +} + +export function removeElementKeyframe({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + keyframeId: string; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + for (const component of binding.components) { + nextAnimations.channels[component.channelId] = removeKeyframe({ + channel: nextAnimations.channels[component.channelId], + keyframeId, + }); + } + const hasRemainingKeys = binding.components.some((component) => + hasChannelKeys({ + channel: nextAnimations.channels[component.channelId], + }), + ); + if (!hasRemainingKeys) { + delete nextAnimations.bindings[propertyPath]; + for (const component of binding.components) { + delete nextAnimations.channels[component.channelId]; + } + } + return toAnimation({ + animations: nextAnimations, + }); +} + +export function retimeElementKeyframe({ + animations, + propertyPath, + keyframeId, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + keyframeId: string; + time: number; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + for (const component of binding.components) { + nextAnimations.channels[component.channelId] = retimeKeyframe({ + channel: nextAnimations.channels[component.channelId], + keyframeId, + time, + }); + } + return toAnimation({ + animations: nextAnimations, + }); +} diff --git a/apps/web/src/animation/property-groups.ts b/apps/web/src/animation/property-groups.ts new file mode 100644 index 00000000..7b71af15 --- /dev/null +++ b/apps/web/src/animation/property-groups.ts @@ -0,0 +1,39 @@ +import type { + AnimationPropertyGroup, + AnimationPropertyPath, + ElementAnimations, +} from "@/animation/types"; +import { ANIMATION_PROPERTY_GROUPS } from "@/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/animation/property-registry.ts b/apps/web/src/animation/property-registry.ts new file mode 100644 index 00000000..aacb9972 --- /dev/null +++ b/apps/web/src/animation/property-registry.ts @@ -0,0 +1,390 @@ +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, +} from "@/animation/types"; +import { parseColorToLinearRgba } from "./binding-values"; +import type { TimelineElement } from "@/timeline"; +import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, +} from "@/text/background"; +import { + canElementHaveAudio, + isVisualElement, +} from "@/timeline/element-utils"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { DEFAULTS } from "@/timeline/defaults"; +import { snapToStep } from "@/utils/math"; + +export interface NumericSpec { + min?: number; + max?: number; + step?: number; +} + +export interface AnimationPropertyDefinition { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + supportsElement: ({ element }: { element: TimelineElement }) => boolean; + getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: AnimationValue; + }) => TimelineElement; +} + +function applyNumericSpec({ + value, + numericRange, +}: { + value: number; + numericRange: NumericSpec | undefined; +}): number { + if (!numericRange) { + return value; + } + + const steppedValue = + numericRange.step != null + ? snapToStep({ value, step: numericRange.step }) + : value; + const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; + const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); +} + +function coerceNumberValue({ + value, + numericRange, +}: { + value: AnimationValue; + numericRange?: NumericSpec; +}): number | null { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + return applyNumericSpec({ value, numericRange }); +} + +function coerceColorValue({ + value, +}: { + value: AnimationValue; +}): string | null { + return typeof value === "string" && parseColorToLinearRgba({ color: value }) + ? value + : null; +} + +function createNumberPropertyDefinition({ + numericRange, + supportsElement, + getValue, + setValue, +}: { + numericRange?: NumericSpec; + supportsElement: AnimationPropertyDefinition["supportsElement"]; + getValue: AnimationPropertyDefinition["getValue"]; + setValue: AnimationPropertyDefinition["setValue"]; +}): AnimationPropertyDefinition { + return { + kind: "number", + defaultInterpolation: "linear", + numericRanges: numericRange ? { value: numericRange } : undefined, + supportsElement, + getValue, + coerceValue: ({ value }) => + coerceNumberValue({ + value, + numericRange, + }), + setValue, + }; +} + +const ANIMATION_PROPERTY_REGISTRY: Record< + AnimationPropertyPath, + AnimationPropertyDefinition +> = { + "transform.positionX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position.x : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: { ...element.transform.position, x: value as number }, + }, + } + : element, + }), + "transform.positionY": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position.y : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: { ...element.transform.position, y: value as number }, + }, + } + : element, + }), + "transform.scaleX": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleX : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleX: value as number }, + } + : element, + }), + "transform.scaleY": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleY : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleY: value as number }, + } + : element, + }), + "transform.rotate": createNumberPropertyDefinition({ + numericRange: { min: -360, max: 360, step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.rotate : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, rotate: value as number }, + } + : element, + }), + opacity: createNumberPropertyDefinition({ + numericRange: { min: 0, max: 1, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.opacity : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { ...element, opacity: value as number } + : element, + }), + volume: createNumberPropertyDefinition({ + numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, + supportsElement: ({ element }) => canElementHaveAudio(element), + getValue: ({ element }) => + canElementHaveAudio(element) ? element.volume ?? 0 : null, + setValue: ({ element, value }) => + canElementHaveAudio(element) + ? { ...element, volume: value as number } + : element, + }), + color: { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => (element.type === "text" ? element.color : null), + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { ...element, color: value as string } + : element, + }, + "background.color": { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" ? element.background.color : null, + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, color: value as string }, + } + : element, + }, + "background.paddingX": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingX: value as number }, + } + : element, + }), + "background.paddingY": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingY: value as number }, + } + : element, + }), + "background.offsetX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetX: value as number }, + } + : element, + }), + "background.offsetY": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetY: value as number }, + } + : element, + }), + "background.cornerRadius": createNumberPropertyDefinition({ + numericRange: { + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + step: 1, + }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, cornerRadius: value as number }, + } + : element, + }), +}; + +export function isAnimationPropertyPath( + propertyPath: string, +): propertyPath is AnimationPropertyPath { + return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); +} + +export function getAnimationPropertyDefinition({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationPropertyDefinition { + return ANIMATION_PROPERTY_REGISTRY[propertyPath]; +} + +export function supportsAnimationProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): boolean { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.supportsElement({ element }); +} + +export function getElementBaseValueForProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): AnimationValue | null { + const definition = getAnimationPropertyDefinition({ propertyPath }); + if (!definition.supportsElement({ element })) { + return null; + } + return definition.getValue({ element }); +} + +export function withElementBaseValueForProperty({ + element, + propertyPath, + value, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): TimelineElement { + const definition = getAnimationPropertyDefinition({ propertyPath }); + const coercedValue = definition.coerceValue({ value }); + if (coercedValue === null || !definition.supportsElement({ element })) { + return element; + } + return definition.setValue({ element, value: coercedValue }); +} + +export function getDefaultInterpolationForProperty({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationInterpolation { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.defaultInterpolation; +} + +export function coerceAnimationValueForProperty({ + propertyPath, + value, +}: { + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): AnimationValue | null { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.coerceValue({ value }); +} diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts new file mode 100644 index 00000000..1b51c126 --- /dev/null +++ b/apps/web/src/animation/resolve.ts @@ -0,0 +1,184 @@ +import type { + AnimationColorPropertyPath, + AnimationNumericPropertyPath, + AnimationPath, + AnimationPropertyPath, + AnimationValueForPath, + ElementAnimations, +} from "@/animation/types"; +import type { Transform } from "@/rendering"; +import { + type AnimationComponentValue, + composeAnimationValue, + decomposeAnimationValue, +} from "./binding-values"; +import { + getChannelValueAtTime, +} from "./interpolation"; + +export function getElementLocalTime({ + timelineTime, + elementStartTime, + elementDuration, +}: { + timelineTime: number; + elementStartTime: number; + elementDuration: number; +}): number { + const localTime = timelineTime - elementStartTime; + if (localTime <= 0) { + return 0; + } + + if (localTime >= elementDuration) { + return elementDuration; + } + + return localTime; +} + +export function resolveTransformAtTime({ + baseTransform, + animations, + localTime, +}: { + baseTransform: Transform; + animations: ElementAnimations | undefined; + localTime: number; +}): Transform { + const safeLocalTime = Math.max(0, localTime); + return { + position: { + x: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.positionX", + localTime: safeLocalTime, + fallbackValue: baseTransform.position.x, + }), + y: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.positionY", + localTime: safeLocalTime, + fallbackValue: baseTransform.position.y, + }), + }, + scaleX: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleX", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleX, + }), + scaleY: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleY", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleY, + }), + rotate: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.rotate", + localTime: safeLocalTime, + fallbackValue: baseTransform.rotate, + }), + }; +} + +export function resolveOpacityAtTime({ + baseOpacity, + animations, + localTime, +}: { + baseOpacity: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath: "opacity", + localTime: Math.max(0, localTime), + fallbackValue: baseOpacity, + }); +} + +export function resolveNumberAtTime({ + baseValue, + animations, + propertyPath, + localTime, +}: { + baseValue: number; + animations: ElementAnimations | undefined; + propertyPath: AnimationNumericPropertyPath; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseValue, + }); +} + +export function resolveColorAtTime({ + baseColor, + animations, + propertyPath, + localTime, +}: { + baseColor: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationColorPropertyPath; + localTime: number; +}): string { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseColor, + }); +} + +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: TPath; + localTime: number; + fallbackValue: AnimationValueForPath; +}): AnimationValueForPath { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return fallbackValue; + } + + const fallbackComponents = decomposeAnimationValue({ + kind: binding.kind, + value: fallbackValue, + }); + if (!fallbackComponents) { + return fallbackValue; + } + + const componentValues = Object.fromEntries( + binding.components.map((component) => { + const channel = animations?.channels[component.channelId]; + return [ + component.key, + getChannelValueAtTime({ + channel, + time: localTime, + fallbackValue: + fallbackComponents[component.key] ?? + (channel?.kind === "discrete" ? false : 0), + }), + ]; + }), + ) as Record; + return (composeAnimationValue({ + binding, + componentValues, + }) ?? fallbackValue) as AnimationValueForPath; +} diff --git a/apps/web/src/animation/target-resolver.ts b/apps/web/src/animation/target-resolver.ts new file mode 100644 index 00000000..32e7917c --- /dev/null +++ b/apps/web/src/animation/target-resolver.ts @@ -0,0 +1,285 @@ +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPath, + AnimationValue, +} from "@/animation/types"; +import { + parseEffectParamPath, + isEffectParamPath, +} from "@/animation/effect-param-channel"; +import { + isGraphicParamPath, + parseGraphicParamPath, +} from "@/animation/graphic-param-channel"; +import type { ParamDefinition } from "@/params"; +import { effectsRegistry, registerDefaultEffects } from "@/effects"; +import { getGraphicDefinition } from "@/graphics"; +import type { TimelineElement } from "@/timeline"; +import { isVisualElement } from "@/timeline/element-utils"; +import { snapToStep } from "@/utils/math"; +import { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getElementBaseValueForProperty, + isAnimationPropertyPath, + type NumericSpec, + withElementBaseValueForProperty, +} from "./property-registry"; +import { parseColorToLinearRgba } from "./binding-values"; + +export interface AnimationPathDescriptor { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + getBaseValue: () => AnimationValue | null; + setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; +} + +export function getParamValueKind({ + param, +}: { + param: ParamDefinition; +}): AnimationBindingKind { + if (param.type === "number") { + return "number"; + } + + if (param.type === "color") { + return "color"; + } + + return "discrete"; +} + +export function getParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): AnimationInterpolation { + return param.type === "number" || param.type === "color" ? "linear" : "hold"; +} + +function getParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): Partial> | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + value: { + min: param.min, + max: param.max, + step: param.step, + }, + }; +} + +export function coerceAnimationValueForParam({ + param, + value, +}: { + param: ParamDefinition; + value: AnimationValue; +}): number | string | boolean | null { + if (param.type === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const minValue = param.min; + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); + } + + if (param.type === "color") { + return typeof value === "string" ? value : null; + } + + if (param.type === "boolean") { + return typeof value === "boolean" ? value : null; + } + + if (typeof value !== "string") { + return null; + } + + return param.options.some((option) => option.value === value) ? value : null; +} + +function buildGraphicParamDescriptor({ + element, + paramKey, +}: { + element: TimelineElement; + paramKey: string; +}): AnimationPathDescriptor | null { + if (element.type !== "graphic") { + return null; + } + + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: getParamNumericRange({ param }), + coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), + getBaseValue: () => element.params[param.key] ?? param.default, + setBaseValue: ({ value }) => { + const coercedValue = coerceAnimationValueForParam({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + params: { + ...element.params, + [param.key]: coercedValue, + }, + }; + }, + }; +} + +function buildEffectParamDescriptor({ + element, + effectId, + paramKey, +}: { + element: TimelineElement; + effectId: string; + paramKey: string; +}): AnimationPathDescriptor | null { + if (!isVisualElement(element)) { + return null; + } + + const effect = element.effects?.find((candidate) => candidate.id === effectId); + if (!effect) { + return null; + } + + registerDefaultEffects(); + const definition = effectsRegistry.get(effect.type); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: getParamNumericRange({ param }), + coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), + getBaseValue: () => effect.params[param.key] ?? param.default, + setBaseValue: ({ value }) => { + const coercedValue = coerceAnimationValueForParam({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + effects: + element.effects?.map((candidate) => + candidate.id !== effectId + ? candidate + : { + ...candidate, + params: { + ...candidate.params, + [param.key]: coercedValue, + }, + }, + ) ?? element.effects, + }; + }, + }; +} + +export function isAnimationPath( + propertyPath: string, +): propertyPath is AnimationPath { + return ( + isAnimationPropertyPath(propertyPath) || + isGraphicParamPath(propertyPath) || + isEffectParamPath(propertyPath) + ); +} + +export function resolveAnimationTarget({ + element, + path, +}: { + element: TimelineElement; + path: AnimationPath; +}): AnimationPathDescriptor | null { + if (isAnimationPropertyPath(path)) { + const propertyDefinition = getAnimationPropertyDefinition({ + propertyPath: path, + }); + if (!propertyDefinition.supportsElement({ element })) { + return null; + } + + return { + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + numericRanges: propertyDefinition.numericRanges, + coerceValue: ({ value }) => + coerceAnimationValueForProperty({ + propertyPath: path, + value, + }), + getBaseValue: () => + getElementBaseValueForProperty({ + element, + propertyPath: path, + }), + setBaseValue: ({ value }) => { + const coercedValue = propertyDefinition.coerceValue({ value }); + if (coercedValue === null) { + return element; + } + + return withElementBaseValueForProperty({ + element, + propertyPath: path, + value: coercedValue, + }); + }, + }; + } + + const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); + if (graphicParamTarget) { + return buildGraphicParamDescriptor({ + element, + paramKey: graphicParamTarget.paramKey, + }); + } + + const effectParamTarget = parseEffectParamPath({ propertyPath: path }); + if (effectParamTarget) { + return buildEffectParamDescriptor({ + element, + effectId: effectParamTarget.effectId, + paramKey: effectParamTarget.paramKey, + }); + } + + return null; +} diff --git a/apps/web/src/animation/timeline-snap-points.ts b/apps/web/src/animation/timeline-snap-points.ts new file mode 100644 index 00000000..f7bf549a --- /dev/null +++ b/apps/web/src/animation/timeline-snap-points.ts @@ -0,0 +1,35 @@ +import { getElementKeyframes } from "@/animation"; +import type { SceneTracks } from "@/timeline"; +import type { SnapPoint } from "@/timeline/snapping"; + +export function getAnimationKeyframeSnapPointsForTimeline({ + tracks, + excludeElementIds, +}: { + tracks: SceneTracks; + excludeElementIds?: Set; +}): SnapPoint[] { + const snapPoints: SnapPoint[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + + for (const track of orderedTracks) { + for (const element of track.elements) { + if (excludeElementIds?.has(element.id)) { + continue; + } + + for (const keyframe of getElementKeyframes({ + animations: element.animations, + })) { + snapPoints.push({ + time: element.startTime + keyframe.time, + type: "keyframe", + elementId: element.id, + trackId: track.id, + }); + } + } + } + + return snapPoints; +} diff --git a/apps/web/src/animation/transform.ts b/apps/web/src/animation/transform.ts new file mode 100644 index 00000000..ab932ea2 --- /dev/null +++ b/apps/web/src/animation/transform.ts @@ -0,0 +1 @@ +export const MIN_TRANSFORM_SCALE = 0.01; diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts new file mode 100644 index 00000000..3afddfa1 --- /dev/null +++ b/apps/web/src/animation/types.ts @@ -0,0 +1,222 @@ +import type { ParamValues } from "@/params"; + +export const ANIMATION_PROPERTY_PATHS = [ + "transform.positionX", + "transform.positionY", + "transform.scaleX", + "transform.scaleY", + "transform.rotate", + "opacity", + "volume", + "color", + "background.color", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", + "background.cornerRadius", +] as const; + +export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; +export type GraphicParamPath = `params.${string}`; +export type EffectParamPath = `effects.${string}.params.${string}`; +export type AnimationPath = + | AnimationPropertyPath + | GraphicParamPath + | EffectParamPath; + +export const ANIMATION_PROPERTY_GROUPS = { + "transform.scale": ["transform.scaleX", "transform.scaleY"], +} as const satisfies Record>; + +export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; + +export type VectorValue = { x: number; y: number }; +export type DiscreteValue = boolean | string; +export type AnimationValue = number | string | boolean | VectorValue; +export interface AnimationPropertyValueMap { + "transform.positionX": number; + "transform.positionY": number; + "transform.scaleX": number; + "transform.scaleY": number; + "transform.rotate": number; + opacity: number; + volume: number; + color: string; + "background.color": string; + "background.paddingX": number; + "background.paddingY": number; + "background.offsetX": number; + "background.offsetY": number; + "background.cornerRadius": number; +} +export type DynamicAnimationPathValue = ParamValues[string]; +export type AnimationValueForPath = + TPath extends AnimationPropertyPath + ? AnimationPropertyValueMap[TPath] + : TPath extends GraphicParamPath | EffectParamPath + ? DynamicAnimationPathValue + : never; +export type AnimationNumericPropertyPath = { + [K in AnimationPropertyPath]: AnimationValueForPath extends number ? K : never; +}[AnimationPropertyPath]; +export type AnimationColorPropertyPath = { + [K in AnimationPropertyPath]: AnimationValueForPath extends string ? K : never; +}[AnimationPropertyPath]; + +export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; +export type DiscreteKeyframeInterpolation = "hold"; +export type AnimationInterpolation = + | ContinuousKeyframeInterpolation + | DiscreteKeyframeInterpolation; + +export type PrimitiveAnimationChannelKind = "scalar" | "discrete"; +export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete"; +export type ScalarSegmentType = "step" | "linear" | "bezier"; +export type TangentMode = "auto" | "aligned" | "broken" | "flat"; +export type ChannelExtrapolationMode = "hold" | "linear"; + +export interface CurveHandle { + dt: number; + dv: number; +} + +interface BaseAnimationKeyframe { + id: string; + time: number; // relative to element start time + value: TValue; +} + +export interface ScalarAnimationKey extends BaseAnimationKeyframe { + leftHandle?: CurveHandle; + rightHandle?: CurveHandle; + segmentToNext: ScalarSegmentType; + tangentMode: TangentMode; +} + +export interface DiscreteAnimationKey + extends BaseAnimationKeyframe {} + +export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey; + +export interface ScalarAnimationChannel { + kind: "scalar"; + keys: ScalarAnimationKey[]; + extrapolation?: { + before: ChannelExtrapolationMode; + after: ChannelExtrapolationMode; + }; +} + +export interface DiscreteAnimationChannel { + kind: "discrete"; + keys: DiscreteAnimationKey[]; +} + +export type AnimationChannel = + | ScalarAnimationChannel + | DiscreteAnimationChannel; + +export type ElementAnimationChannelMap = Record< + string, + AnimationChannel | undefined +>; + +export interface AnimationBindingComponent { + key: TKey; + channelId: string; +} + +interface BaseAnimationBinding< + TKind extends AnimationBindingKind, + TComponentKey extends string, +> { + path: AnimationPath; + kind: TKind; + components: AnimationBindingComponent[]; +} + +export interface NumberAnimationBinding + extends BaseAnimationBinding<"number", "value"> {} + +export interface Vector2AnimationBinding + extends BaseAnimationBinding<"vector2", "x" | "y"> {} + +export interface ColorAnimationBinding + extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> { + colorSpace: "srgb-linear"; +} + +export interface DiscreteAnimationBinding + extends BaseAnimationBinding<"discrete", "value"> {} + +export type AnimationBindingInstance = + | NumberAnimationBinding + | Vector2AnimationBinding + | ColorAnimationBinding + | DiscreteAnimationBinding; + +export interface AnimationBindingByKind { + number: NumberAnimationBinding; + vector2: Vector2AnimationBinding; + color: ColorAnimationBinding; + discrete: DiscreteAnimationBinding; +} + +export type AnimationBindingOfKind = + AnimationBindingByKind[TKind]; + +export type ElementAnimationBindingMap = Record< + string, + AnimationBindingInstance | undefined +>; + +export interface ElementAnimations { + bindings: ElementAnimationBindingMap; + channels: ElementAnimationChannelMap; +} + +export type NormalizedCubicBezier = [number, number, number, number]; + +export interface ScalarGraphChannelTarget { + propertyPath: AnimationPath; + componentKey: string; + channelId: string; +} + +export interface ScalarGraphChannel extends ScalarGraphChannelTarget { + channel: ScalarAnimationChannel; +} + +export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget { + keyframeId: string; +} + +export interface ScalarGraphKeyframeContext extends ScalarGraphChannel { + keyframe: ScalarAnimationKey; + keyframeIndex: number; + previousKey: ScalarAnimationKey | null; + nextKey: ScalarAnimationKey | null; +} + +export interface ScalarCurveKeyframePatch { + leftHandle?: CurveHandle | null; + rightHandle?: CurveHandle | null; + segmentToNext?: ScalarSegmentType; + tangentMode?: TangentMode; +} + +export interface ElementKeyframe { + propertyPath: AnimationPath; + id: string; + time: number; + value: AnimationValue; + interpolation: AnimationInterpolation; +} + +export interface SelectedKeyframeRef { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; +} diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts index 544457e2..edf0d0c2 100644 --- a/apps/web/src/app/api/auth/[...all]/route.ts +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@/lib/auth/server"; +import { auth } from "@/auth/server"; import { toNextJsHandler } from "better-auth/next-js"; export const { POST, GET } = toNextJsHandler(auth); diff --git a/apps/web/src/app/api/feedback/route.ts b/apps/web/src/app/api/feedback/route.ts index b13ec2bf..cd853eea 100644 --- a/apps/web/src/app/api/feedback/route.ts +++ b/apps/web/src/app/api/feedback/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/lib/feedback"; +import { checkRateLimit } from "@/auth/rate-limit"; +import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/feedback"; const submitSchema = z.object({ message: z diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts index c4d065a9..aa7a1b60 100644 --- a/apps/web/src/app/api/sounds/search/route.ts +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -1,7 +1,7 @@ -import { webEnv } from "@/lib/env/web"; +import { webEnv } from "@/env/web"; import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { checkRateLimit } from "@/lib/rate-limit"; +import { checkRateLimit } from "@/auth/rate-limit"; const searchParamsSchema = z.object({ q: z.string().max(500, "Query too long").optional(), diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index b581de95..dba9b504 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -4,8 +4,8 @@ import { notFound } from "next/navigation"; 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 "@/lib/blog/types"; +import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query"; +import type { Author, Post } from "@/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 9f5a8972..b40bc2fc 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -2,8 +2,8 @@ import type { Metadata } from "next"; 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 "@/lib/blog/types"; +import { getPosts } from "@/blog/query"; +import type { Post } from "@/blog/types"; export const metadata: Metadata = { title: "Blog - OpenCut", diff --git a/apps/web/src/app/changelog/[version]/page.tsx b/apps/web/src/app/changelog/[version]/page.tsx index f069832b..6c529ce1 100644 --- a/apps/web/src/app/changelog/[version]/page.tsx +++ b/apps/web/src/app/changelog/[version]/page.tsx @@ -3,15 +3,15 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { BasePage } from "@/app/base-page"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { getReleaseByVersion, getSortedReleases } from "@/lib/changelog/utils"; +import { getReleaseByVersion, getSortedReleases } from "@/changelog/utils"; import { ReleaseArticle, ReleaseMeta, ReleaseTitle, ReleaseDescription, ReleaseChanges, -} from "@/lib/changelog/components/release"; -import { CopyMarkdownButton } from "@/lib/changelog/components/copy-markdown-button"; +} from "@/changelog/components/release"; +import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button"; type Props = { params: Promise<{ version: string }> }; diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx index 8440eaa2..25ae25db 100644 --- a/apps/web/src/app/changelog/page.tsx +++ b/apps/web/src/app/changelog/page.tsx @@ -4,14 +4,14 @@ import { Separator } from "@/components/ui/separator"; import { type Release as ReleaseType, getSortedReleases, -} from "@/lib/changelog/utils"; +} from "@/changelog/utils"; import { ReleaseArticle, ReleaseMeta, ReleaseTitle, ReleaseDescription, ReleaseChanges, -} from "@/lib/changelog/components/release"; +} from "@/changelog/components/release"; export const metadata: Metadata = { title: "Changelog - OpenCut", diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index b15d9f27..f4d13732 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { GitHubContributeSection } from "@/components/gitHub-contribute-section"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Card, CardContent } from "@/components/ui/card"; -import { EXTERNAL_TOOLS } from "@/lib/site/external-tools"; +import { EXTERNAL_TOOLS } from "@/site/external-tools"; import { BasePage } from "../base-page"; export const metadata: Metadata = { diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index a3a7afa5..b3d40a37 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -8,21 +8,32 @@ import { } from "@/components/ui/resizable"; import { AssetsPanel } from "@/components/editor/panels/assets"; import { PropertiesPanel } from "@/components/editor/panels/properties"; -import { Timeline } from "@/components/editor/panels/timeline"; -import { PreviewPanel } from "@/components/editor/panels/preview"; +import { Timeline } from "@/timeline/components"; +import { PreviewPanel } from "@/preview/components"; import { EditorHeader } from "@/components/editor/editor-header"; import { EditorProvider } from "@/components/providers/editor-provider"; import { Onboarding } from "@/components/editor/onboarding"; -import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; -import { usePanelStore } from "@/stores/panel-store"; -import { usePasteMedia } from "@/hooks/use-paste-media"; +import { MigrationDialog } from "@/project/components/migration-dialog"; +import { usePanelStore } from "@/editor/panel-store"; +import { usePasteMedia } from "@/media/use-paste-media"; import { MobileGate } from "@/components/editor/mobile-gate"; -import { useState } from "react"; -import { useEditor } from "@/hooks/use-editor"; +import { useMemo, useState } from "react"; +import { useEditor } from "@/editor/use-editor"; import { Cancel01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Button } from "@/components/ui/button"; -import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification"; +import { ChangelogNotification } from "@/changelog/components/changelog-notification"; +import { + createPreviewOverlayControl, + isPreviewOverlayVisible, + mergePreviewOverlaySources, +} from "@/preview/overlays"; +import { usePreviewStore } from "@/preview/preview-store"; +import { getGuidePreviewOverlaySource } from "@/guides"; +import { + bookmarkNotesPreviewOverlay, + getBookmarkPreviewOverlaySource, +} from "@/timeline/bookmarks/index"; export default function Editor() { const params = useParams(); @@ -70,6 +81,49 @@ function DegradedRendererBanner() { function EditorLayout() { usePasteMedia(); const { panels, setPanel } = usePanelStore(); + const activeScene = useEditor((editor) => + editor.scenes.getActiveSceneOrNull(), + ); + const currentTime = useEditor((editor) => editor.playback.getCurrentTime()); + const activeGuide = usePreviewStore((state) => state.activeGuide); + const overlays = usePreviewStore((state) => state.overlays); + const setOverlayVisibility = usePreviewStore( + (state) => state.setOverlayVisibility, + ); + const showBookmarkNotes = isPreviewOverlayVisible({ + overlay: bookmarkNotesPreviewOverlay, + overlays, + }); + + const overlaySource = useMemo( + () => + mergePreviewOverlaySources({ + sources: [ + getGuidePreviewOverlaySource({ + guideId: activeGuide, + }), + activeScene + ? getBookmarkPreviewOverlaySource({ + bookmarks: activeScene.bookmarks, + time: currentTime, + isVisible: showBookmarkNotes, + }) + : { + definitions: [bookmarkNotesPreviewOverlay], + instances: [], + }, + ], + }), + [activeGuide, activeScene, currentTime, showBookmarkNotes], + ); + + const overlayControls = useMemo( + () => + overlaySource.definitions.map((overlay) => + createPreviewOverlayControl({ overlay, overlays }), + ), + [overlaySource.definitions, overlays], + ); return ( - + diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 6d4878c5..558ae538 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,11 +2,11 @@ import { ThemeProvider } from "next-themes"; import Script from "next/script"; import "./globals.css"; import { Toaster } from "../components/ui/sonner"; -import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification"; +import { ChangelogNotification } from "@/changelog/components/changelog-notification"; import { TooltipProvider } from "../components/ui/tooltip"; import { baseMetaData } from "./metadata"; import { BotIdClient } from "botid/client"; -import { webEnv } from "@/lib/env/web"; +import { webEnv } from "@/env/web"; import { Inter } from "next/font/google"; const siteFont = Inter({ subsets: ["latin"] }); diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts index 06276baa..aa11c43e 100644 --- a/apps/web/src/app/metadata.ts +++ b/apps/web/src/app/metadata.ts @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { SITE_INFO, SITE_URL } from "@/lib/site/brand"; +import { SITE_INFO, SITE_URL } from "@/site/brand"; export const baseMetaData: Metadata = { metadataBase: new URL(SITE_URL), diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 460e8bc3..6084a912 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -2,7 +2,7 @@ import { Hero } from "@/components/landing/hero"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; import type { Metadata } from "next"; -import { SITE_URL } from "@/lib/site/brand"; +import { SITE_URL } from "@/site/brand"; export const metadata: Metadata = { alternates: { diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 4963e2ab..6362146e 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -7,7 +7,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Separator } from "@/components/ui/separator"; -import { SOCIAL_LINKS } from "@/lib/site/social"; +import { SOCIAL_LINKS } from "@/site/social"; export const metadata: Metadata = { title: "Privacy Policy - OpenCut", diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index e8b09291..c39c4fd9 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -7,20 +7,20 @@ import type { KeyboardEvent, MouseEvent } from "react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import type { EditorCore } from "@/core"; -import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; -import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog"; +import { MigrationDialog } from "@/project/components/migration-dialog"; +import { StoragePersistenceDialog } from "@/services/storage/components/storage-persistence-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; -import { useEditor } from "@/hooks/use-editor"; +import { useEditor } from "@/editor/use-editor"; import { useProjectsStore } from "./store"; import type { TProjectMetadata, TProjectSortKey, TProjectSortOption, -} from "@/lib/project/types"; +} from "@/project/types"; import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm"; import { formatDate } from "@/utils/date"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -62,11 +62,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog"; -import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog"; -import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog"; +import { DeleteProjectDialog } from "@/project/components/delete-project-dialog"; +import { ProjectInfoDialog } from "@/project/components/project-info-dialog"; +import { RenameProjectDialog } from "@/project/components/rename-project-dialog"; import { cn } from "@/utils/ui"; -import { ChangelogNotification } from "@/lib/changelog/components/changelog-notification"; +import { ChangelogNotification } from "@/changelog/components/changelog-notification"; const formatProjectDuration = ({ duration, }: { diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts index 71f89b4a..82d2744e 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 "@/lib/project/types"; +import type { TProjectSortKey } from "@/project/types"; export type ProjectsViewMode = "grid" | "list"; diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts index 38f6f487..1b1ca865 100644 --- a/apps/web/src/app/robots.ts +++ b/apps/web/src/app/robots.ts @@ -1,5 +1,5 @@ import type { MetadataRoute } from "next"; -import { SITE_URL } from "@/lib/site/brand"; +import { SITE_URL } from "@/site/brand"; export default function robots(): MetadataRoute.Robots { return { diff --git a/apps/web/src/app/rss.xml/route.ts b/apps/web/src/app/rss.xml/route.ts index 4dfccd59..d4e9570a 100644 --- a/apps/web/src/app/rss.xml/route.ts +++ b/apps/web/src/app/rss.xml/route.ts @@ -1,6 +1,6 @@ import { Feed } from "feed"; -import { getPosts } from "@/lib/blog/query"; -import { SITE_INFO, SITE_URL } from "@/lib/site/brand"; +import { getPosts } from "@/blog/query"; +import { SITE_INFO, SITE_URL } from "@/site/brand"; export async function GET() { try { diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index 7b404642..73b4180f 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -1,5 +1,5 @@ -import { SITE_URL } from "@/lib/site/brand"; -import { getPosts } from "@/lib/blog/query"; +import { SITE_URL } from "@/site/brand"; +import { getPosts } from "@/blog/query"; import type { MetadataRoute } from "next"; export default async function sitemap(): Promise { diff --git a/apps/web/src/app/sponsors/page.tsx b/apps/web/src/app/sponsors/page.tsx index 0de78c85..510419f0 100644 --- a/apps/web/src/app/sponsors/page.tsx +++ b/apps/web/src/app/sponsors/page.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import Link from "next/link"; import { BasePage } from "@/app/base-page"; import { Card, CardContent } from "@/components/ui/card"; -import { SPONSORS, type Sponsor } from "@/lib/site/sponsors"; +import { SPONSORS, type Sponsor } from "@/site/sponsors"; import { HugeiconsIcon } from "@hugeicons/react"; import { LinkSquare02Icon } from "@hugeicons/core-free-icons"; import { cn } from "@/utils/ui"; diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx index 73a6cce1..0845892c 100644 --- a/apps/web/src/app/terms/page.tsx +++ b/apps/web/src/app/terms/page.tsx @@ -7,7 +7,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Separator } from "@/components/ui/separator"; -import { SOCIAL_LINKS } from "@/lib/site/social"; +import { SOCIAL_LINKS } from "@/site/social"; export const metadata: Metadata = { title: "Terms of Service - OpenCut", diff --git a/apps/web/src/auth/client.ts b/apps/web/src/auth/client.ts new file mode 100644 index 00000000..58be51c3 --- /dev/null +++ b/apps/web/src/auth/client.ts @@ -0,0 +1,6 @@ +import { createAuthClient } from "better-auth/react"; +import { webEnv } from "@/env/web"; + +export const { signIn, signUp, useSession } = createAuthClient({ + baseURL: webEnv.NEXT_PUBLIC_SITE_URL, +}); diff --git a/apps/web/src/auth/rate-limit.ts b/apps/web/src/auth/rate-limit.ts new file mode 100644 index 00000000..c10c3819 --- /dev/null +++ b/apps/web/src/auth/rate-limit.ts @@ -0,0 +1,21 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import { webEnv } from "@/env/web"; + +const redis = new Redis({ + url: webEnv.UPSTASH_REDIS_REST_URL, + token: webEnv.UPSTASH_REDIS_REST_TOKEN, +}); + +export const baseRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute + analytics: true, + prefix: "rate-limit", +}); + +export async function checkRateLimit({ request }: { request: Request }) { + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + return { success, limited: !success }; +} diff --git a/apps/web/src/auth/server.ts b/apps/web/src/auth/server.ts new file mode 100644 index 00000000..5e49387a --- /dev/null +++ b/apps/web/src/auth/server.ts @@ -0,0 +1,43 @@ +import { betterAuth, type RateLimit } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { Redis } from "@upstash/redis"; +import { db } from "@/db"; +import { webEnv } from "@/env/web"; + +const redis = new Redis({ + url: webEnv.UPSTASH_REDIS_REST_URL, + token: webEnv.UPSTASH_REDIS_REST_TOKEN, +}); + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + usePlural: true, + }), + secret: webEnv.BETTER_AUTH_SECRET, + user: { + deleteUser: { + enabled: true, + }, + }, + emailAndPassword: { + enabled: true, + }, + rateLimit: { + storage: "secondary-storage", + customStorage: { + get: async (key) => { + const value = await redis.get(key); + return value as RateLimit | undefined; + }, + set: async (key, value) => { + await redis.set(key, value); + }, + }, + }, + baseURL: webEnv.NEXT_PUBLIC_SITE_URL, + appName: "OpenCut", + trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL], +}); + +export type Auth = typeof auth; diff --git a/apps/web/src/background/blur.ts b/apps/web/src/background/blur.ts new file mode 100644 index 00000000..3b111482 --- /dev/null +++ b/apps/web/src/background/blur.ts @@ -0,0 +1,10 @@ +export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{ + label: string; + value: number; +}> = [ + { label: "Light", value: 100 }, + { label: "Medium", value: 200 }, + { label: "Heavy", value: 500 }, +] as const; + +export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; diff --git a/apps/web/src/background/color.ts b/apps/web/src/background/color.ts new file mode 100644 index 00000000..7b86faf5 --- /dev/null +++ b/apps/web/src/background/color.ts @@ -0,0 +1 @@ +export const DEFAULT_BACKGROUND_COLOR = "#000000"; diff --git a/apps/web/src/blog/query.ts b/apps/web/src/blog/query.ts new file mode 100644 index 00000000..a7742db2 --- /dev/null +++ b/apps/web/src/blog/query.ts @@ -0,0 +1,133 @@ +import type { + MarbleAuthorList, + MarbleCategoryList, + Pagination, + MarblePost, + MarblePostList, + MarbleTagList, +} from "@/blog/types"; +import { unified } from "unified"; +import rehypeParse from "rehype-parse"; +import rehypeStringify from "rehype-stringify"; +import rehypeSlug from "rehype-slug"; +import rehypeAutolinkHeadings from "rehype-autolink-headings"; +import rehypeSanitize from "rehype-sanitize"; + +const url = + process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com"; +const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46"; + +const EMPTY_PAGINATION: Pagination = { + limit: 0, + currpage: 1, + nextPage: null, + prevPage: null, + totalItems: 0, + totalPages: 0, +}; + +const EMPTY_POSTS: MarblePostList = { + posts: [], + pagination: EMPTY_PAGINATION, +}; + +const EMPTY_TAGS: MarbleTagList = { + tags: [], + pagination: EMPTY_PAGINATION, +}; + +const EMPTY_CATEGORIES: MarbleCategoryList = { + categories: [], + pagination: EMPTY_PAGINATION, +}; + +const EMPTY_AUTHORS: MarbleAuthorList = { + authors: [], + pagination: EMPTY_PAGINATION, +}; + +function isMarbleConfigured() { + return ![ + "", + "placeholder", + "build-placeholder", + "your_workspace_key_here", + ].includes(key); +} + +async function fetchFromMarble({ + endpoint, + fallback, +}: { + endpoint: string; + fallback: T; +}): Promise { + if (!isMarbleConfigured()) { + return fallback; + } + + try { + const response = await fetch(`${url}/${key}/${endpoint}`); + if (!response.ok) { + console.warn( + `Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`, + ); + return fallback; + } + return (await response.json()) as T; + } catch (error) { + console.error(`Error fetching ${endpoint}:`, error); + return fallback; + } +} + +export async function getPosts() { + return fetchFromMarble({ + endpoint: "posts", + fallback: EMPTY_POSTS, + }); +} + +export async function getTags() { + return fetchFromMarble({ + endpoint: "tags", + fallback: EMPTY_TAGS, + }); +} + +export async function getSinglePost({ slug }: { slug: string }) { + return fetchFromMarble({ + endpoint: `posts/${slug}`, + fallback: null, + }); +} + +export async function getCategories() { + return fetchFromMarble({ + endpoint: "categories", + fallback: EMPTY_CATEGORIES, + }); +} + +export async function getAuthors() { + return fetchFromMarble({ + endpoint: "authors", + fallback: EMPTY_AUTHORS, + }); +} + +export async function processHtmlContent({ + html, +}: { + html: string; +}): Promise { + const processor = unified() + .use(rehypeSanitize) + .use(rehypeParse, { fragment: true }) + .use(rehypeSlug) + .use(rehypeAutolinkHeadings, { behavior: "append" }) + .use(rehypeStringify); + + const file = await processor.process({ value: html, type: "html" }); + return String(file); +} diff --git a/apps/web/src/blog/types.ts b/apps/web/src/blog/types.ts new file mode 100644 index 00000000..7b2f450a --- /dev/null +++ b/apps/web/src/blog/types.ts @@ -0,0 +1,92 @@ +export type Post = { + id: string; + slug: string; + title: string; + content: string; + description: string; + coverImage: string; + publishedAt: Date; + updatedAt: Date; + authors: { + id: string; + name: string; + image: string; + }[]; + category: { + id: string; + slug: string; + name: string; + }; + tags: { + id: string; + slug: string; + name: string; + }[]; + attribution: { + author: string; + url: string; + } | null; +}; + +export type Pagination = { + limit: number; + currpage: number; + nextPage: number | null; + prevPage: number | null; + totalItems: number; + totalPages: number; +}; + +export type MarblePostList = { + posts: Post[]; + pagination: Pagination; +}; + +export type MarblePost = { + post: Post; +}; + +export type Tag = { + id: string; + name: string; + slug: string; +}; + +export type MarbleTag = { + tag: Tag; +}; + +export type MarbleTagList = { + tags: Tag[]; + pagination: Pagination; +}; + +export type Category = { + id: string; + name: string; + slug: string; +}; + +export type MarbleCategory = { + category: Category; +}; + +export type MarbleCategoryList = { + categories: Category[]; + pagination: Pagination; +}; + +export type Author = { + id: string; + name: string; + image: string; +}; + +export type MarbleAuthor = { + author: Author; +}; + +export type MarbleAuthorList = { + authors: Author[]; + pagination: Pagination; +}; diff --git a/apps/web/src/canvas/sizes.ts b/apps/web/src/canvas/sizes.ts new file mode 100644 index 00000000..76361336 --- /dev/null +++ b/apps/web/src/canvas/sizes.ts @@ -0,0 +1,10 @@ +import type { TCanvasSize } from "@/project/types"; + +export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [ + { width: 1920, height: 1080 }, + { width: 1080, height: 1920 }, + { width: 1080, height: 1080 }, + { width: 1440, height: 1080 }, +]; + +export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 }; diff --git a/apps/web/src/changelog/components/changelog-notification.tsx b/apps/web/src/changelog/components/changelog-notification.tsx new file mode 100644 index 00000000..4433df30 --- /dev/null +++ b/apps/web/src/changelog/components/changelog-notification.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { Cancel01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Button } from "@/components/ui/button"; +import { getSortedReleases } from "../utils"; +import type { Release } from "../utils"; + +const STORAGE_KEY = "last-seen-version"; + +export function ChangelogNotification() { + const [release, setRelease] = useState(null); + + useEffect(() => { + const releases = getSortedReleases(); + const latest = releases[0]; + if (!latest) return; + + let storedVersion: string | null = null; + try { + storedVersion = localStorage.getItem(STORAGE_KEY); + } catch { + // localStorage unavailable + } + + const isOutdated = + storedVersion === null || + storedVersion.localeCompare(latest.version, undefined, { + numeric: true, + }) < 0; + + // TODO(v0.4): revert to the standard "null = first-time visitor, record silently" + // path. The null case intentionally shows the card for this release so existing + // users who never had the key get the 0.3.0 announcement. + if (!isOutdated) return; + + try { + localStorage.setItem(STORAGE_KEY, latest.version); + } catch { + // ignore + } + + setRelease(latest); + }, []); + + if (!release) return null; + + return ( +
+
+
+ + {release.title} + + + v{release.version} + +
+ +
+ + {release.summary && ( +

+ {release.summary} +

+ )} + +
+ +
+
+ ); +} diff --git a/apps/web/src/changelog/components/copy-markdown-button.tsx b/apps/web/src/changelog/components/copy-markdown-button.tsx new file mode 100644 index 00000000..bc7e94bf --- /dev/null +++ b/apps/web/src/changelog/components/copy-markdown-button.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState } from "react"; +import { CheckIcon, ClipboardIcon } from "lucide-react"; +import { + getSectionTitle, + groupAndOrderChanges, + isSectionCollapsible, +} from "../utils"; +import type { Change } from "../utils"; +import { cn } from "@/utils/ui"; +import { Button } from "@/components/ui/button"; + +function buildMarkdown({ + description, + changes, +}: { + description?: string; + changes: Change[]; +}): string { + const lines: string[] = []; + + if (description) { + lines.push(description, ""); + } + + const { grouped, orderedTypes } = groupAndOrderChanges({ changes }); + + for (const type of orderedTypes) { + if (isSectionCollapsible({ type })) { + lines.push( + buildCollapsibleMarkdownSection({ + title: getSectionTitle({ type }), + changes: grouped[type], + }), + "", + ); + continue; + } + + lines.push(`## ${getSectionTitle({ type })}`); + for (const change of grouped[type]) { + lines.push(`- ${change.text}`); + } + lines.push(""); + } + + return lines.join("\n").trimEnd(); +} + +function buildCollapsibleMarkdownSection({ + title, + changes, +}: { + title: string; + changes: Change[]; +}): string { + const bulletLines = changes.map((change) => `- ${change.text}`).join("\n"); + + return `
\n${title}\n\n${bulletLines}\n\n
`; +} + +export function CopyMarkdownButton({ + description, + changes, +}: { + description?: string; + changes: Change[]; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + const markdown = buildMarkdown({ description, changes }); + await navigator.clipboard.writeText(markdown); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} diff --git a/apps/web/src/changelog/components/release.tsx b/apps/web/src/changelog/components/release.tsx new file mode 100644 index 00000000..475b0a6d --- /dev/null +++ b/apps/web/src/changelog/components/release.tsx @@ -0,0 +1,153 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import { cn } from "@/utils/ui"; +import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper"; +import { + getSectionTitle, + groupAndOrderChanges, + isSectionCollapsible, + type Change, + type Release, +} from "../utils"; +import { ArrowRightIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +export function ReleaseArticle({ + variant, + isLatest, + children, +}: { + variant: "list" | "detail"; + isLatest?: boolean; + children: ReactNode; +}) { + if (variant === "list") { + return ( +
+
+
+
+
{children}
+
+ ); + } + + return
{children}
; +} + +export function ReleaseMeta({ release }: { release: Release }) { + return ( + + {release.version} — {release.date} + + ); +} + +const titleSizes: Record<"h1" | "h2", string> = { + h1: "text-4xl", + h2: "text-2xl", +}; + +export function ReleaseTitle({ + as: As, + href, + children, +}: { + as: "h1" | "h2"; + href?: string; + children: ReactNode; +}) { + return ( + + {href ? ( + + {children} + + ) : ( + children + )} + + ); +} + +export function ReleaseDescription({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function ReleaseChanges({ release }: { release: Release }) { + const { grouped, orderedTypes } = groupAndOrderChanges({ + changes: release.changes, + }); + + return ( +
+ {orderedTypes.map((type) => ( + + ))} +
+ ); +} + +function ReleaseChangeSection({ + type, + changes, +}: { + type: string; + changes: Change[]; +}) { + const title = getSectionTitle({ type }); + + if (isSectionCollapsible({ type })) { + return ( +
+ + {title}: + + + +
+ ); + } + + return ( +
+

{title}:

+ +
+ ); +} + +function ReleaseChangeList({ + changes, + className, +}: { + changes: Change[]; + className?: string; +}) { + return ( +
    + {changes.map((change) => ( +
  • + {change.text} +
  • + ))} +
+ ); +} diff --git a/apps/web/src/changelog/entries/0.1.0.md b/apps/web/src/changelog/entries/0.1.0.md new file mode 100644 index 00000000..33c5413c --- /dev/null +++ b/apps/web/src/changelog/entries/0.1.0.md @@ -0,0 +1,37 @@ +--- +version: "0.1.0" +date: "2026-02-23" +published: true +title: "Editor foundation" +description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use." +summary: "Properties panel overhaul, 1,000+ fonts, blend modes, a new color picker, and direct manipulation in the preview." +changes: + - type: new + text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in." + - type: new + text: "The properties panel went from a flat list of basic text styles to organized sections: transform, blending, typography, spacing, and background controls. Text elements finally have position, scale, and rotation." + - type: new + text: "New color picker with eyedropper, opacity control, and multiple color formats." + - type: new + text: "Bookmarks now support notes, color labels, and optional duration to plan scenes more clearly." + - type: new + text: "Move, scale, and rotate elements directly in the preview panel." + - type: new + text: "Blend modes (Multiply, Screen, Overlay, etc). Only available on text elements for now." + - type: new + text: "Paste images, videos, or audio from your clipboard straight into the editor." + - type: new + text: "Hold Shift while moving or resizing to temporarily turn off snapping." + - type: improved + text: "Expanded font selection from 7 to over 1,000." + - type: improved + text: "Fonts load much faster." + - type: improved + text: "All asset panel tabs now share a consistent layout." + - type: improved + text: "Text rendering properly supports multiple lines, line height, and letter spacing." + - type: improved + text: "Better contrast in the dark theme." + - type: fixed + text: "Fixed undo/redo for drag-and-drop so dropped items are reliably undoable." +--- diff --git a/apps/web/src/changelog/entries/0.2.0.md b/apps/web/src/changelog/entries/0.2.0.md new file mode 100644 index 00000000..261b47d2 --- /dev/null +++ b/apps/web/src/changelog/entries/0.2.0.md @@ -0,0 +1,47 @@ +--- +version: "0.2.0" +date: "2026-03-01" +published: true +title: "Motion & effects" +description: "This release adds the foundation for motion and effects, alongside many improvements & fixes." +summary: "Keyframe animation, a new effects system with per-clip blur, and ripple editing mode." +changes: + - type: new + text: "Keyframe animation system for animating element properties over time." + - type: new + text: "Entirely new efects system. Effects can be applied per-clip or added as standalone elements on the timeline (with our first effect: Blur!)" + - type: new + text: "Added a changelog page to the site." + - type: new + text: "Ripple editing mode." + - type: fixed + text: "Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below." + - type: fixed + text: "Audio could flicker or cut out at the start of playback when certain elements were selected. The scheduler now recovers from timing slips without losing audio." + - type: fixed + text: "Dragging a clip onto the timeline and pressing Ctrl+Z now removes both the clip and the track it created in one step, instead of requiring two undos." + - type: fixed + text: "A few values in the properties panel were previously showing incorrect values. This has been fixed." + - type: improved + text: "New text elements no longer have a black background by default." + - type: improved + text: "Selection outline in the preview is now dashed." + - type: improved + text: "Clips sitting next to each other on the timeline now have a small gap between them so selected clips are always clearly visible." + - type: fixed + text: "Timeline trim handles now sit outside the clip instead of inside them, so the clip's visible area accurately represents where it starts and ends." + - type: fixed + text: "Clips being dragged on the timeline now render on top of other clips instead of underneath them." + - type: fixed + text: "Undoing a trim-from-start operation now correctly restores the clip to its original position and length, instead of stretching it to the right." + - type: fixed + text: "Resizing a clip on the timeline would deselect it when the mouse was released. The clip now stays selected after a resize." + - type: improved + text: "Press Escape to deselect the current element." + - type: fixed + text: "Resizing a clip could extend it past another clip on the same track, causing an overlap. The resize now stops at the next clip's edge." + - type: fixed + text: "Dragging a clip to a different track would deselect it on drop. The clip stays selected after moving." + - type: fixed + text: "Finishing a resize or drag with the mouse over empty track space would deselect the clip." +--- diff --git a/apps/web/src/changelog/entries/0.3.0.md b/apps/web/src/changelog/entries/0.3.0.md new file mode 100644 index 00000000..86c62cff --- /dev/null +++ b/apps/web/src/changelog/entries/0.3.0.md @@ -0,0 +1,114 @@ +--- +version: "0.3.0" +date: "2026-04-15" +published: true +title: "Masks, animation & more" +description: "This release adds masks, a graph editor for keyframe curves, volume and speed controls, preview zoom, canvas backgrounds, stickers, and a lot more." +summary: "Masks, graph editor for curves, volume & speed controls, preview zoom, canvas backgrounds, and stickers." +changes: + - type: new + text: "Added a brand page with downloadable brand assets. Assets are also accessible directly from the header logo context menu." + - type: fixed + text: "Sponsor logos now correctly invert in dark mode." + - type: improved + text: "Added a changelog link to the footer." + - type: fixed + text: "Media asset cards in the grid view were larger than their thumbnails, leaving empty space around each item. Cards now size to their content." + - type: improved + text: "Playback performance is dramatically better. The editor used to slow to a crawl during playback, and audio would stutter when interacting with elements mid-play. Both are fixed." + - type: fixed + text: "Project thumbnails now generate correctly even when the timeline is empty. If you set a background color or gradient with no clips, the thumbnail reflects that instead of staying blank." + - type: fixed + text: "Clicking a selected element no longer keeps the rest of the selection." + - type: improved + text: "More vertical space between timeline tracks. Selection outlines on adjacent tracks no longer overlap each other." + - type: fixed + text: "Track labels on the left side of the timeline were misaligned with the tracks on the right." + - type: improved + text: "Holding Shift or Ctrl while drag-selecting in the timeline now adds to your existing selection instead of replacing it. Matches how Shift/Ctrl+click already worked." + - type: improved + text: "Timeline track rows now highlight when they contain a selected element, making it easier to see which tracks are active at a glance." + - type: fixed + text: "Clicking in the empty space below timeline tracks now seeks the playhead." + - type: fixed + text: "Timeline vertical scroll now works anywhere in the panel, not just over the track labels. The header no longer scrolls with the tracks." + - type: new + text: "Scale now has separate width and height controls. You can stretch or squash elements independently on each axis." + - type: improved + text: "System fonts (Arial, Helvetica, Times New Roman, and others) now appear in the font picker alongside Google Fonts." + - type: fixed + text: "MP4 exports with audio failed on Firefox. The export now completes successfully regardless of browser." + - type: improved + text: "Your projects can disappear if your disk runs low on space. OpenCut now asks the browser to protect them." + - type: improved + text: "The properties panel has been redesigned with a tabbed layout." + - type: new + text: "Volume control for audio and video elements." + - type: new + text: "Speed control for video and audio clips. Includes a maintain pitch option so audio doesn't chipmunk or deepen when you change the rate." + - type: improved + text: "Audio waveforms have gotten an upgrade. The old ones were inaccurate, prone to crashing, and not very useful. The new ones are RMS-based, properly scaled, and only render what's visible so they don't slow things down." + - type: improved + text: "The timeline now opens scrolled to the bottom, so your main video track is visible right away instead of being hidden above the fold." + - type: fixed + text: "Snap guides in the preview panel didn't show when moving a full-canvas element to the center of the frame." + - type: fixed + text: "The rotation handle could float above the editor UI when an element was near the top of the canvas. It now clips to the preview area." + - type: fixed + text: "Images with a blur effect at 0% intensity would disappear completely when a blurred background was also active. Now 0% blur correctly renders the image unchanged." + - type: fixed + text: "Section titles in the properties panel showed the wrong text color when the section wasn't collapsible. Non-collapsible titles now use the correct muted style." + - type: new + text: "Masks. Hide or reveal parts of any video or image layer. Choose from split, rectangle, ellipse, star, heart, diamond, and cinematic bars. Adjust position, size, rotation, feather, and stroke in the properties panel, or drag the handles in the preview." + - type: improved + text: "The settings panel has been redesigned with a cleaner layout and better organization." + - type: new + text: "Canvas backgrounds are back. Set a blur, solid color, or gradient as the canvas background from the new Background tab in the settings panel." + - type: fixed + text: "Blur looked pixelated at higher intensities instead of producing a real blur. Both the canvas background blur and the blur effect are now fixed." + - type: new + text: "Custom canvas size. Instead of being locked to a handful of presets, you can now enter any width and height you want." + - type: new + text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in." + - type: new + text: "Stickers panel now has content. Browse and add country flags and shapes." + - type: improved + text: "You can now select multiple assets in the assets panel. Useful for deleting several at once." + - type: fixed + text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed." + - type: new + text: "You can now import transcript files to generate captions instead of running auto-transcription." + - type: new + text: "Graph editor for keyframe curves. Shape the easing between keyframes by dragging bezier handles." + - type: fixed + text: "Text element handles no longer shift position as you type." + - type: new + text: "Multiple selected timeline clips move and resize together." + - type: fixed + text: "The rotation handle drifted further and further from the element as the canvas size got smaller. On a 100x100 project it was almost off-screen. The gap is now a fixed size regardless of canvas dimensions or zoom." + - type: new + text: "Position X and Y can now be animated independently." + - type: new + text: "Keyframes can be copied and pasted between elements." + - type: new + text: 'Timeline clips can expand to show a lane for each animated property. Right-click a clip -> "Expand keyframes" to see all properties in their own rows.' + - type: improved + text: "If your browser doesn't support GPU-accelerated rendering, the editor now shows a notice explaining why and suggests switching to another browser." + - type: technical + text: "Ripple editing now has a proper algorithmic foundation. The old inline approach was replaced with a diff-based system in `src/lib/ripple/`: track snapshots before and after an operation are compared as interval sets, vacated intervals are computed (accounting for elements moved to other tracks), and the result is a list of explicit `RippleAdjustment` values applied in a separate pass. Composable, testable, and independent of the command that triggered it." + - type: technical + text: "Time is now represented as `MediaTime`, an integer tick count at 120,000 ticks per second, defined in `rust/crates/time`. Previously, floating-point seconds were used throughout, which accumulated rounding errors and made frame alignment imprecise. 120,000 was chosen because it divides evenly by every standard frame rate denominator, including drop-frame rates: 23.976 maps to 5,005 ticks/frame, 29.97 to 4,004, 30 to 4,000. `FrameRate` is a rational `{numerator, denominator}` type rather than a float. All time arithmetic, frame snapping, and timecode formatting are implemented in Rust and exposed via WASM." + - type: technical + text: "Track ordering is now enforced at the type level. `SceneTracks` replaces a flat track array with three explicit fields: `overlay`, `main` (a singleton `VideoTrack`), and `audio`. The type makes it structurally impossible to mix track kinds or misplace a track." + - type: technical + text: "The WebGL renderer has been replaced with a Rust/wgpu compositor compiled to WASM. The `rust/` crate tree covers compositing (`rust/crates/compositor`), effects (`rust/crates/effects`), masks with JFA-based feathering (`rust/crates/masks`), GPU context (`rust/crates/gpu`), and time (`rust/crates/time`). WASM bindings live in `rust/wasm/` and are consumed from TypeScript through `src/lib/wasm/`. The old GLSL shaders and WebGL utilities are gone." + - type: technical + text: "`src/types/` and `src/constants/` have been dissolved. All types and constants now live alongside the code that owns them inside `src/lib/`. The `packages/env` and `packages/ui` internal packages were also folded into the web app, with icons at `src/components/icons/` and env at `src/lib/env/`." + - type: technical + text: "The web app can now deploy to Cloudflare Workers via OpenNext. `wrangler.jsonc` and `apps/web/open-next.config.ts` are included for anyone self-hosting." + - type: technical + text: "`apps/desktop/` is a new GPUI + Rust app. It is early: there is a `main.rs` and a working build setup, but no features yet." + - type: technical + text: "16 new storage migration steps were added (v9 through v25), covering the schema changes accumulated during this release cycle." + +--- diff --git a/apps/web/src/changelog/entries/0.4.0.md b/apps/web/src/changelog/entries/0.4.0.md new file mode 100644 index 00000000..141df133 --- /dev/null +++ b/apps/web/src/changelog/entries/0.4.0.md @@ -0,0 +1,19 @@ +--- +version: "0.3.0" +date: "2026-03-04" +published: false +title: "Title" +description: "Description" +changes: +- type: new + text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight." +- type: new + text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke." +- type: fixed + text: "Preview panning now keeps working while the Masks tab is open." +- type: new + text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position." +- type: improved + text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top." +- type: fixed + text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio." \ No newline at end of file diff --git a/apps/web/src/changelog/utils.ts b/apps/web/src/changelog/utils.ts new file mode 100644 index 00000000..682d376a --- /dev/null +++ b/apps/web/src/changelog/utils.ts @@ -0,0 +1,82 @@ +import { allChangelogs } from "content-collections"; + +export type Change = { type: string; text: string }; +export type Release = (typeof allChangelogs)[number]; + +type ChangeSectionConfig = { + title: string; + order: number; + collapsible?: boolean; +}; + +const knownSectionConfigs: Record = { + new: { title: "Features", order: 0 }, + improved: { title: "Improvements", order: 1 }, + fixed: { title: "Fixes", order: 2 }, + breaking: { title: "Breaking Changes", order: 3 }, + technical: { + title: "Technical details", + order: 4, + collapsible: true, + }, +}; + +function getSectionConfig({ type }: { type: string }): ChangeSectionConfig { + return ( + knownSectionConfigs[type] ?? { + title: type.charAt(0).toUpperCase() + type.slice(1), + order: Number.MAX_SAFE_INTEGER, + } + ); +} + +export function getSectionTitle({ type }: { type: string }): string { + return getSectionConfig({ type }).title; +} + +export function isSectionCollapsible({ type }: { type: string }): boolean { + return getSectionConfig({ type }).collapsible ?? false; +} + +export function groupAndOrderChanges({ changes }: { changes: Change[] }) { + const typeEncounterOrder = new Map(); + const grouped = changes.reduce>((acc, change) => { + if (!typeEncounterOrder.has(change.type)) { + typeEncounterOrder.set(change.type, typeEncounterOrder.size); + } + if (!acc[change.type]) acc[change.type] = []; + acc[change.type].push(change); + return acc; + }, {}); + + const orderedTypes = Object.keys(grouped).sort((left, right) => { + const orderDifference = + getSectionConfig({ type: left }).order - + getSectionConfig({ type: right }).order; + if (orderDifference !== 0) { + return orderDifference; + } + return ( + (typeEncounterOrder.get(left) ?? Number.MAX_SAFE_INTEGER) - + (typeEncounterOrder.get(right) ?? Number.MAX_SAFE_INTEGER) + ); + }); + + return { grouped, orderedTypes }; +} + +function isPublishedRelease({ published }: Release) { + return published !== false; +} + +export function getSortedReleases() { + return allChangelogs + .filter(isPublishedRelease) + .sort((a, b) => + b.version.localeCompare(a.version, undefined, { numeric: true }), + ); +} + +export function getReleaseByVersion({ version }: { version: string }) { + return getSortedReleases().find((release) => release.version === version); +} diff --git a/apps/web/src/clipboard/handlers/elements.ts b/apps/web/src/clipboard/handlers/elements.ts new file mode 100644 index 00000000..b2f5f2c3 --- /dev/null +++ b/apps/web/src/clipboard/handlers/elements.ts @@ -0,0 +1,48 @@ +import { PasteCommand } from "@/commands/timeline"; +import type { ClipboardHandler } from "../types"; + +export const ElementsClipboardHandler = { + type: "elements", + + canCopy({ selectedElements }) { + return selectedElements.length > 0; + }, + + copy({ editor, selectedElements }) { + if (selectedElements.length === 0) { + return null; + } + + const results = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + }); + const items = results.map(({ track, element }) => { + const { id: _elementId, ...elementWithoutId } = element; + return { + trackId: track.id, + trackType: track.type, + element: elementWithoutId, + }; + }); + + if (items.length === 0) { + return null; + } + + return { + type: "elements", + items, + }; + }, + + paste(entry, context) { + if (entry.items.length === 0) { + return null; + } + + return new PasteCommand({ + time: context.time, + clipboardItems: entry.items, + }); + }, +} satisfies ClipboardHandler<"elements">; diff --git a/apps/web/src/clipboard/handlers/index.ts b/apps/web/src/clipboard/handlers/index.ts new file mode 100644 index 00000000..29d4d1d0 --- /dev/null +++ b/apps/web/src/clipboard/handlers/index.ts @@ -0,0 +1,49 @@ +import type { + ClipboardEntry, + ClipboardEntryType, + ClipboardHandler, + ClipboardHandlerMap, + CopyContext, + PasteContext, +} from "../types"; +import { ElementsClipboardHandler } from "./elements"; +import { KeyframesClipboardHandler } from "./keyframes"; + +export const clipboardHandlers = { + elements: ElementsClipboardHandler, + keyframes: KeyframesClipboardHandler, +} satisfies ClipboardHandlerMap; + +export const clipboardCopyHandlers = [ + KeyframesClipboardHandler, + ElementsClipboardHandler, +] as const satisfies readonly ClipboardHandler[]; + +export function copyClipboardEntry({ + context, +}: { + context: CopyContext; +}): ClipboardEntry | null { + for (const handler of clipboardCopyHandlers) { + if (!handler.canCopy(context)) { + continue; + } + + return handler.copy(context); + } + + return null; +} + +export function buildPasteClipboardCommand({ + entry, + context, +}: { + entry: ClipboardEntry; + context: PasteContext; +}) { + const handler = clipboardHandlers[entry.type] as ClipboardHandler< + typeof entry.type + >; + return handler.paste(entry as never, context); +} diff --git a/apps/web/src/clipboard/handlers/keyframes.ts b/apps/web/src/clipboard/handlers/keyframes.ts new file mode 100644 index 00000000..ad156e12 --- /dev/null +++ b/apps/web/src/clipboard/handlers/keyframes.ts @@ -0,0 +1,170 @@ +import { getKeyframeById } from "@/animation"; +import type { SelectedKeyframeRef } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import { PasteKeyframesCommand } from "@/commands/timeline"; +import type { + ClipboardHandler, + KeyframeClipboardCurvePatch, + KeyframeClipboardItem, +} from "../types"; + +function resolveSingleSourceElement({ + selectedKeyframes, +}: { + selectedKeyframes: SelectedKeyframeRef[]; +}) { + const firstKeyframe = selectedKeyframes[0]; + if (!firstKeyframe) { + return null; + } + + const sourceElement = { + trackId: firstKeyframe.trackId, + elementId: firstKeyframe.elementId, + }; + const isSingleSource = selectedKeyframes.every( + (keyframe) => + keyframe.trackId === sourceElement.trackId && + keyframe.elementId === sourceElement.elementId, + ); + + return isSingleSource ? sourceElement : null; +} + +function getCurvePatches({ + element, + propertyPath, + keyframeId, +}: { + element: TimelineElement; + propertyPath: KeyframeClipboardItem["propertyPath"]; + keyframeId: string; +}): KeyframeClipboardCurvePatch[] { + const binding = element.animations?.bindings[propertyPath]; + if (!binding) { + return []; + } + + return binding.components.flatMap((component) => { + const channel = element.animations?.channels[component.channelId]; + if (channel?.kind !== "scalar") { + return []; + } + + const keyframe = channel.keys.find( + (candidate) => candidate.id === keyframeId, + ); + if (!keyframe) { + return []; + } + + return [ + { + componentKey: component.key, + patch: { + leftHandle: keyframe.leftHandle ?? null, + rightHandle: keyframe.rightHandle ?? null, + segmentToNext: keyframe.segmentToNext, + tangentMode: keyframe.tangentMode, + }, + }, + ]; + }); +} + +function buildClipboardItem({ + element, + propertyPath, + keyframeId, +}: { + element: TimelineElement; + propertyPath: KeyframeClipboardItem["propertyPath"]; + keyframeId: string; +}): (Omit & { time: number }) | null { + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath, + keyframeId, + }); + if (!keyframe) { + return null; + } + + return { + propertyPath, + time: keyframe.time, + value: keyframe.value, + interpolation: keyframe.interpolation, + curvePatches: getCurvePatches({ + element, + propertyPath, + keyframeId, + }), + }; +} + +export const KeyframesClipboardHandler = { + type: "keyframes", + + canCopy({ selectedKeyframes }) { + return selectedKeyframes.length > 0; + }, + + copy({ editor, selectedKeyframes }) { + const sourceElement = resolveSingleSourceElement({ selectedKeyframes }); + if (!sourceElement) { + return null; + } + + const [result] = editor.timeline.getElementsWithTracks({ + elements: [sourceElement], + }); + if (!result) { + return null; + } + + const rawItems = selectedKeyframes.flatMap((keyframeRef) => { + const item = buildClipboardItem({ + element: result.element, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + }); + return item ? [item] : []; + }); + if (rawItems.length === 0) { + return null; + } + + const minTime = Math.min(...rawItems.map((item) => item.time)); + const items = rawItems + .map(({ time, ...item }) => ({ + ...item, + timeOffset: time - minTime, + })) + .sort( + (left, right) => + left.timeOffset - right.timeOffset || + left.propertyPath.localeCompare(right.propertyPath), + ); + + return { + type: "keyframes", + sourceElement, + items, + }; + }, + + paste(entry, { selectedElements, time }) { + const targetElement = selectedElements[0]; + if (!targetElement || entry.items.length === 0) { + return null; + } + + return new PasteKeyframesCommand({ + trackId: targetElement.trackId, + elementId: targetElement.elementId, + time, + clipboardItems: entry.items, + }); + }, +} satisfies ClipboardHandler<"keyframes">; diff --git a/apps/web/src/clipboard/index.ts b/apps/web/src/clipboard/index.ts new file mode 100644 index 00000000..76d5bddf --- /dev/null +++ b/apps/web/src/clipboard/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./handlers"; diff --git a/apps/web/src/clipboard/types.ts b/apps/web/src/clipboard/types.ts new file mode 100644 index 00000000..0d249c9d --- /dev/null +++ b/apps/web/src/clipboard/types.ts @@ -0,0 +1,79 @@ +import type { EditorCore } from "@/core"; +import type { + AnimationInterpolation, + AnimationPath, + AnimationValue, + ScalarCurveKeyframePatch, + SelectedKeyframeRef, +} from "@/animation/types"; +import type { Command } from "@/commands/base-command"; +import type { + CreateTimelineElement, + ElementRef, + TrackType, +} from "@/timeline"; + +export interface ElementClipboardItem { + trackId: string; + trackType: TrackType; + element: CreateTimelineElement; +} + +export interface KeyframeClipboardCurvePatch { + componentKey: string; + patch: ScalarCurveKeyframePatch; +} + +export interface KeyframeClipboardItem { + propertyPath: AnimationPath; + timeOffset: number; + value: AnimationValue; + interpolation: AnimationInterpolation; + curvePatches: KeyframeClipboardCurvePatch[]; +} + +export interface ElementsClipboardEntry { + type: "elements"; + items: ElementClipboardItem[]; +} + +export interface KeyframesClipboardEntry { + type: "keyframes"; + sourceElement: ElementRef; + items: KeyframeClipboardItem[]; +} + +export interface ClipboardEntryByType { + elements: ElementsClipboardEntry; + keyframes: KeyframesClipboardEntry; +} + +export type ClipboardEntry = ClipboardEntryByType[keyof ClipboardEntryByType]; +export type ClipboardEntryType = keyof ClipboardEntryByType; + +export interface CopyContext { + editor: EditorCore; + selectedElements: ElementRef[]; + selectedKeyframes: SelectedKeyframeRef[]; +} + +export interface PasteContext { + editor: EditorCore; + selectedElements: ElementRef[]; + selectedKeyframes: SelectedKeyframeRef[]; + time: number; +} + +export interface ClipboardHandler { + type: TType; + canCopy(context: CopyContext): boolean; + copy(context: CopyContext): ClipboardEntryByType[TType] | null; + paste( + entry: ClipboardEntryByType[TType], + context: PasteContext, + ): Command | null; +} + +export type ClipboardHandlerMap = { + [TType in ClipboardEntryType]: ClipboardHandler; +}; diff --git a/apps/web/src/commands/base-command.ts b/apps/web/src/commands/base-command.ts new file mode 100644 index 00000000..2ab9af09 --- /dev/null +++ b/apps/web/src/commands/base-command.ts @@ -0,0 +1,31 @@ +import type { EditorSelectionPatch } from "@/selection/editor-selection"; +import type { ElementRef } from "@/timeline/types"; + +export interface CommandResult { + selection?: EditorSelectionPatch; +} + +export function createElementSelectionResult( + selectedElements: ElementRef[], +): CommandResult { + return { + selection: { + selectedElements, + selectedKeyframes: [], + keyframeSelectionAnchor: null, + selectedMaskPoints: null, + }, + }; +} + +export abstract class Command { + abstract execute(): CommandResult | undefined; + + undo(): void { + throw new Error("Undo not implemented for this command"); + } + + redo(): CommandResult | undefined { + return this.execute(); + } +} diff --git a/apps/web/src/commands/batch-command.ts b/apps/web/src/commands/batch-command.ts new file mode 100644 index 00000000..c4ee7e0b --- /dev/null +++ b/apps/web/src/commands/batch-command.ts @@ -0,0 +1,39 @@ +import { Command, type CommandResult } from "./base-command"; + +export class BatchCommand extends Command { + constructor(private commands: Command[]) { + super(); + } + + execute(): CommandResult | undefined { + let latestSelectionResult: CommandResult | undefined; + + for (const command of this.commands) { + const result = command.execute(); + if (result?.selection !== undefined) { + latestSelectionResult = result; + } + } + + return latestSelectionResult; + } + + undo(): void { + for (const command of [...this.commands].reverse()) { + command.undo(); + } + } + + redo(): CommandResult | undefined { + let latestSelectionResult: CommandResult | undefined; + + for (const command of this.commands) { + const result = command.redo(); + if (result?.selection !== undefined) { + latestSelectionResult = result; + } + } + + return latestSelectionResult; + } +} diff --git a/apps/web/src/commands/index.ts b/apps/web/src/commands/index.ts new file mode 100644 index 00000000..78927ca9 --- /dev/null +++ b/apps/web/src/commands/index.ts @@ -0,0 +1,8 @@ +export { Command } from "./base-command"; +export type { CommandResult } from "./base-command"; +export { BatchCommand } from "./batch-command"; + +export * from "./timeline"; +export * from "./media"; +export * from "./scene"; +export * from "./project"; diff --git a/apps/web/src/commands/media/add-media-asset.ts b/apps/web/src/commands/media/add-media-asset.ts new file mode 100644 index 00000000..d22241a2 --- /dev/null +++ b/apps/web/src/commands/media/add-media-asset.ts @@ -0,0 +1,135 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { toast } from "sonner"; +import type { MediaAsset } from "@/media/types"; +import { generateUUID } from "@/utils/id"; +import { storageService } from "@/services/storage/service"; +import type { FrameRate } from "opencut-wasm"; +import { hasMediaId } from "@/timeline/element-utils"; +import { frameRatesEqual, getHighestImportedVideoFps } from "@/fps/utils"; +import { UpdateProjectSettingsCommand } from "@/commands/project"; + +export class AddMediaAssetCommand extends Command { + private assetId: string; + private savedAssets: MediaAsset[] | null = null; + private createdAsset: MediaAsset | null = null; + private previousProjectFps: FrameRate | null = null; + private appliedProjectFps: FrameRate | null = null; + + constructor( + private projectId: string, + private asset: Omit, + ) { + super(); + this.assetId = generateUUID(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedAssets = [...editor.media.getAssets()]; + + this.createdAsset = { + ...this.asset, + id: this.assetId, + }; + + editor.media.setAssets({ + assets: [...this.savedAssets, this.createdAsset], + }); + this.previousProjectFps = editor.project.getActiveOrNull()?.settings.fps ?? null; + this.appliedProjectFps = editor.project.ratchetFpsForImportedMedia({ + importedAssets: [this.createdAsset], + }); + + storageService + .saveMediaAsset({ + projectId: this.projectId, + mediaAsset: this.createdAsset, + }) + .catch((error) => { + console.error("Failed to save media item:", error); + + const currentAssets = editor.media.getAssets(); + editor.media.setAssets({ + assets: currentAssets.filter((asset) => asset.id !== this.assetId), + }); + + const currentTracks = editor.scenes.getActiveScene().tracks; + const orphanedElements: Array<{ trackId: string; elementId: string }> = + []; + + for (const track of [ + ...currentTracks.overlay, + currentTracks.main, + ...currentTracks.audio, + ]) { + for (const element of track.elements) { + if (hasMediaId(element) && element.mediaId === this.assetId) { + orphanedElements.push({ + trackId: track.id, + elementId: element.id, + }); + } + } + } + + if (orphanedElements.length > 0) { + editor.timeline.deleteElements({ elements: orphanedElements }); + } + + this.restoreProjectFpsAfterFailedSave({ editor }); + + if (storageService.isQuotaExceededError({ error })) { + toast.error("Not enough browser storage", { + description: error instanceof Error ? error.message : undefined, + }); + } + }); + + return undefined; + } + + undo(): void { + if (this.savedAssets) { + const editor = EditorCore.getInstance(); + editor.media.setAssets({ assets: this.savedAssets }); + + if (this.createdAsset) { + storageService + .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) + .catch((error) => { + console.error("Failed to delete media item on undo:", error); + }); + } + } + } + + getAssetId(): string { + return this.assetId; + } + + private restoreProjectFpsAfterFailedSave({ + editor, + }: { + editor: EditorCore; + }): void { + if (this.previousProjectFps === null || this.appliedProjectFps === null) return; + + const activeProject = editor.project.getActiveOrNull(); + if (!activeProject) return; + if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return; + + const highestRemainingVideoFps = getHighestImportedVideoFps({ + mediaAssets: editor.media.getAssets(), + }); + const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator; + if ( + highestRemainingVideoFps !== null && + highestRemainingVideoFps >= appliedFpsFloat + ) { + return; + } + + new UpdateProjectSettingsCommand({ fps: this.previousProjectFps }).execute(); + } +} diff --git a/apps/web/src/commands/media/index.ts b/apps/web/src/commands/media/index.ts new file mode 100644 index 00000000..466222f2 --- /dev/null +++ b/apps/web/src/commands/media/index.ts @@ -0,0 +1,2 @@ +export { AddMediaAssetCommand } from "./add-media-asset"; +export { RemoveMediaAssetCommand } from "./remove-media-asset"; diff --git a/apps/web/src/commands/media/remove-media-asset.ts b/apps/web/src/commands/media/remove-media-asset.ts new file mode 100644 index 00000000..8da62181 --- /dev/null +++ b/apps/web/src/commands/media/remove-media-asset.ts @@ -0,0 +1,111 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { MediaAsset } from "@/media/types"; +import { buildWaveformSourceKey } from "@/media/waveform-summary"; +import { storageService } from "@/services/storage/service"; +import { videoCache } from "@/services/video-cache/service"; +import { waveformCache } from "@/services/waveform-cache/service"; +import { hasMediaId } from "@/timeline/element-utils"; +import type { SceneTracks } from "@/timeline"; + +export class RemoveMediaAssetCommand extends Command { + private savedAssets: MediaAsset[] | null = null; + private savedTracks: SceneTracks | null = null; + private removedAsset: MediaAsset | null = null; + + constructor( + private projectId: string, + private assetId: string, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const assets = editor.media.getAssets(); + + this.savedAssets = [...assets]; + this.savedTracks = editor.scenes.getActiveScene().tracks; + + this.removedAsset = + assets.find((media) => media.id === this.assetId) ?? null; + + if (!this.removedAsset) { + console.error("Media asset not found:", this.assetId); + return; + } + + if (this.removedAsset.url) { + URL.revokeObjectURL(this.removedAsset.url); + } + if (this.removedAsset.thumbnailUrl) { + URL.revokeObjectURL(this.removedAsset.thumbnailUrl); + } + + videoCache.clearVideo({ mediaId: this.assetId }); + waveformCache.clearSource({ + sourceKey: buildWaveformSourceKey({ + kind: "media", + id: this.assetId, + }), + }); + + editor.media.setAssets({ + assets: assets.filter((media) => media.id !== this.assetId), + }); + + const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; + + for (const track of [ + ...this.savedTracks.overlay, + this.savedTracks.main, + ...this.savedTracks.audio, + ]) { + for (const element of track.elements) { + if (hasMediaId(element) && element.mediaId === this.assetId) { + elementsToRemove.push({ trackId: track.id, elementId: element.id }); + } + } + } + + if (elementsToRemove.length > 0) { + editor.timeline.deleteElements({ elements: elementsToRemove }); + } + + storageService + .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) + .catch((error) => { + console.error("Failed to delete media item:", error); + }); + } + + undo(): void { + const editor = EditorCore.getInstance(); + + if (this.savedAssets && this.removedAsset) { + const restoredAsset: MediaAsset = { + ...this.removedAsset, + url: URL.createObjectURL(this.removedAsset.file), + }; + + editor.media.setAssets({ + assets: this.savedAssets.map((a) => + a.id === this.assetId ? restoredAsset : a, + ), + }); + + storageService + .saveMediaAsset({ + projectId: this.projectId, + mediaAsset: restoredAsset, + }) + .catch((error) => { + console.error("Failed to restore media item on undo:", error); + }); + } + + if (this.savedTracks) { + editor.timeline.updateTracks(this.savedTracks); + } + } +} diff --git a/apps/web/src/commands/preview-tracker.ts b/apps/web/src/commands/preview-tracker.ts new file mode 100644 index 00000000..68c8dee3 --- /dev/null +++ b/apps/web/src/commands/preview-tracker.ts @@ -0,0 +1,23 @@ +export class PreviewTracker { + private snapshot: T | null = null; + + begin({ state }: { state: T }): void { + if (this.snapshot === null) { + this.snapshot = structuredClone(state); + } + } + + isActive(): boolean { + return this.snapshot !== null; + } + + getSnapshot(): T | null { + return this.snapshot; + } + + end(): T | null { + const snapshot = this.snapshot; + this.snapshot = null; + return snapshot; + } +} diff --git a/apps/web/src/commands/project/index.ts b/apps/web/src/commands/project/index.ts new file mode 100644 index 00000000..a9f26edc --- /dev/null +++ b/apps/web/src/commands/project/index.ts @@ -0,0 +1 @@ +export * from "./update-project-settings"; diff --git a/apps/web/src/commands/project/update-project-settings.ts b/apps/web/src/commands/project/update-project-settings.ts new file mode 100644 index 00000000..8d653db2 --- /dev/null +++ b/apps/web/src/commands/project/update-project-settings.ts @@ -0,0 +1,46 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TProject, TProjectSettings } from "@/project/types"; + +export class UpdateProjectSettingsCommand extends Command { + private savedSettings: TProjectSettings | null = null; + private savedUpdatedAt: Date | null = null; + + constructor(private updates: Partial) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + this.savedSettings = activeProject.settings; + this.savedUpdatedAt = activeProject.metadata.updatedAt; + + const updatedProject: TProject = { + ...activeProject, + settings: { ...activeProject.settings, ...this.updates }, + metadata: { ...activeProject.metadata, updatedAt: new Date() }, + }; + + editor.project.setActiveProject({ project: updatedProject }); + editor.save.markDirty(); + } + + undo(): void { + if (!this.savedSettings || !this.savedUpdatedAt) return; + const editor = EditorCore.getInstance(); + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const updatedProject: TProject = { + ...activeProject, + settings: this.savedSettings, + metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt }, + }; + + editor.project.setActiveProject({ project: updatedProject }); + editor.save.markDirty(); + } +} diff --git a/apps/web/src/commands/scene/create-scene.ts b/apps/web/src/commands/scene/create-scene.ts new file mode 100644 index 00000000..b554fe90 --- /dev/null +++ b/apps/web/src/commands/scene/create-scene.ts @@ -0,0 +1,41 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { buildDefaultScene } from "@/timeline/scenes"; + +export class CreateSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private createdScene: TScene | null = null; + + constructor( + private name: string, + private isMain: boolean = false, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedScenes = [...editor.scenes.getScenes()]; + + this.createdScene = buildDefaultScene({ + name: this.name, + isMain: this.isMain, + }); + + const updatedScenes = [...this.savedScenes, this.createdScene]; + editor.scenes.setScenes({ scenes: updatedScenes }); + return undefined; + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } + + getSceneId(): string { + return this.createdScene?.id ?? ""; + } +} diff --git a/apps/web/src/commands/scene/delete-scene.ts b/apps/web/src/commands/scene/delete-scene.ts new file mode 100644 index 00000000..92de518f --- /dev/null +++ b/apps/web/src/commands/scene/delete-scene.ts @@ -0,0 +1,59 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { canDeleteScene, getFallbackSceneAfterDelete } from "@/timeline/scenes"; + +export class DeleteSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private savedActiveSceneId: string | null = null; + private deletedScene: TScene | null = null; + + constructor(private sceneId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const scenes = editor.scenes.getScenes(); + const activeScene = editor.scenes.getActiveScene(); + + this.savedScenes = [...scenes]; + this.savedActiveSceneId = activeScene?.id ?? null; + + this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null; + + if (!this.deletedScene) { + console.error("Scene not found:", this.sceneId); + return; + } + + const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene }); + if (!canDelete) { + console.error("Cannot delete scene:", reason); + return; + } + + const updatedScenes = scenes.filter((s) => s.id !== this.sceneId); + + const newActiveScene = getFallbackSceneAfterDelete({ + scenes: updatedScenes, + deletedSceneId: this.sceneId, + currentSceneId: activeScene?.id ?? null, + }); + + editor.scenes.setScenes({ + scenes: updatedScenes, + activeSceneId: newActiveScene?.id, + }); + } + + undo(): void { + if (this.savedScenes && this.deletedScene) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ + scenes: this.savedScenes, + activeSceneId: this.savedActiveSceneId ?? undefined, + }); + } + } +} diff --git a/apps/web/src/commands/scene/index.ts b/apps/web/src/commands/scene/index.ts new file mode 100644 index 00000000..2b843c99 --- /dev/null +++ b/apps/web/src/commands/scene/index.ts @@ -0,0 +1,7 @@ +export { CreateSceneCommand } from "./create-scene"; +export { DeleteSceneCommand } from "./delete-scene"; +export { RenameSceneCommand } from "./rename-scene"; +export { ToggleBookmarkCommand } from "./toggle-bookmark"; +export { RemoveBookmarkCommand } from "./remove-bookmark"; +export { UpdateBookmarkCommand } from "./update-bookmark"; +export { MoveBookmarkCommand } from "./move-bookmark"; diff --git a/apps/web/src/commands/scene/move-bookmark.ts b/apps/web/src/commands/scene/move-bookmark.ts new file mode 100644 index 00000000..a1624eb0 --- /dev/null +++ b/apps/web/src/commands/scene/move-bookmark.ts @@ -0,0 +1,59 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { updateSceneInArray } from "@/timeline/scenes"; +import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index"; + +export class MoveBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private fromTime: number, + private toTime: number, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const fromFrameTime = getFrameTime({ + time: this.fromTime, + fps: activeProject.settings.fps, + }); + const toFrameTime = getFrameTime({ + time: this.toTime, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = moveBookmarkInArray({ + bookmarks: activeScene.bookmarks, + fromTime: fromFrameTime, + toTime: toFrameTime, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/commands/scene/remove-bookmark.ts b/apps/web/src/commands/scene/remove-bookmark.ts new file mode 100644 index 00000000..ea592556 --- /dev/null +++ b/apps/web/src/commands/scene/remove-bookmark.ts @@ -0,0 +1,59 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { updateSceneInArray } from "@/timeline/scenes"; +import { + getFrameTime, + removeBookmarkFromArray, +} from "@/timeline/bookmarks/index"; + +export class RemoveBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + private frameTime: number = 0; + + constructor(private time: number) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + this.frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = removeBookmarkFromArray({ + bookmarks: activeScene.bookmarks, + frameTime: this.frameTime, + }); + + if (updatedBookmarks.length === activeScene.bookmarks.length) { + return; + } + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/commands/scene/rename-scene.ts b/apps/web/src/commands/scene/rename-scene.ts new file mode 100644 index 00000000..19d6bfd8 --- /dev/null +++ b/apps/web/src/commands/scene/rename-scene.ts @@ -0,0 +1,46 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { updateSceneInArray } from "@/timeline/scenes"; + +export class RenameSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private previousName: string | null = null; + + constructor( + private sceneId: string, + private newName: string, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const scenes = editor.scenes.getScenes(); + + this.savedScenes = [...scenes]; + + const scene = scenes.find((s) => s.id === this.sceneId); + if (!scene) { + console.error("Scene not found:", this.sceneId); + return; + } + + this.previousName = scene.name; + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: this.sceneId, + updates: { name: this.newName, updatedAt: new Date() }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes && this.previousName !== null) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/commands/scene/toggle-bookmark.ts b/apps/web/src/commands/scene/toggle-bookmark.ts new file mode 100644 index 00000000..4193c76e --- /dev/null +++ b/apps/web/src/commands/scene/toggle-bookmark.ts @@ -0,0 +1,55 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { updateSceneInArray } from "@/timeline/scenes"; +import { + getFrameTime, + toggleBookmarkInArray, +} from "@/timeline/bookmarks/index"; + +export class ToggleBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + private frameTime: number = 0; + + constructor(private time: number) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + this.frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = toggleBookmarkInArray({ + bookmarks: activeScene.bookmarks, + frameTime: this.frameTime, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/commands/scene/update-bookmark.ts b/apps/web/src/commands/scene/update-bookmark.ts new file mode 100644 index 00000000..0d6a0193 --- /dev/null +++ b/apps/web/src/commands/scene/update-bookmark.ts @@ -0,0 +1,58 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { Bookmark, TScene } from "@/timeline"; +import { updateSceneInArray } from "@/timeline/scenes"; +import { + getFrameTime, + updateBookmarkInArray, +} from "@/timeline/bookmarks/index"; + +export class UpdateBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private time: number, + private updates: Partial>, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = updateBookmarkInArray({ + bookmarks: activeScene.bookmarks, + frameTime, + updates: this.updates, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/commands/timeline/clipboard/index.ts b/apps/web/src/commands/timeline/clipboard/index.ts new file mode 100644 index 00000000..e893b62b --- /dev/null +++ b/apps/web/src/commands/timeline/clipboard/index.ts @@ -0,0 +1,2 @@ +export { PasteCommand } from "./paste"; +export { PasteKeyframesCommand } from "./paste-keyframes"; diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts new file mode 100644 index 00000000..e05b49fa --- /dev/null +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -0,0 +1,135 @@ +import { EditorCore } from "@/core"; +import { + getKeyframeAtTime, + resolveAnimationTarget, + updateScalarKeyframeCurve, + upsertPathKeyframe, +} from "@/animation"; +import { Command, type CommandResult } from "@/commands/base-command"; +import type { KeyframeClipboardItem } from "@/clipboard"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import { updateElementInSceneTracks } from "@/timeline"; +import { generateUUID } from "@/utils/id"; + +function pasteKeyframesIntoElement({ + element, + time, + clipboardItems, +}: { + element: TimelineElement; + time: number; + clipboardItems: KeyframeClipboardItem[]; +}): TimelineElement { + let nextElement = element; + + for (const item of clipboardItems) { + const target = resolveAnimationTarget({ + element: nextElement, + path: item.propertyPath, + }); + if (!target) { + continue; + } + + const keyframeTime = Math.max( + 0, + Math.min(time + item.timeOffset, nextElement.duration), + ); + const nextAnimations = upsertPathKeyframe({ + animations: nextElement.animations, + propertyPath: item.propertyPath, + time: keyframeTime, + value: item.value, + interpolation: item.interpolation, + keyframeId: generateUUID(), + kind: target.kind, + defaultInterpolation: target.defaultInterpolation, + coerceValue: target.coerceValue, + }); + const pastedKeyframe = getKeyframeAtTime({ + animations: nextAnimations, + propertyPath: item.propertyPath, + time: keyframeTime, + }); + + let patchedAnimations = nextAnimations; + if (pastedKeyframe) { + for (const curvePatch of item.curvePatches) { + const nextPatchedAnimations = updateScalarKeyframeCurve({ + animations: patchedAnimations, + propertyPath: item.propertyPath, + componentKey: curvePatch.componentKey, + keyframeId: pastedKeyframe.id, + patch: curvePatch.patch, + }); + patchedAnimations = nextPatchedAnimations ?? patchedAnimations; + } + } + + nextElement = { + ...nextElement, + animations: patchedAnimations, + }; + } + + return nextElement; +} + +export class PasteKeyframesCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly time: number; + private readonly clipboardItems: KeyframeClipboardItem[]; + + constructor({ + trackId, + elementId, + time, + clipboardItems, + }: { + trackId: string; + elementId: string; + time: number; + clipboardItems: KeyframeClipboardItem[]; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.time = time; + this.clipboardItems = clipboardItems; + } + + execute(): CommandResult | undefined { + if (this.clipboardItems.length === 0) { + return undefined; + } + + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => + pasteKeyframesIntoElement({ + element, + time: this.time, + clipboardItems: this.clipboardItems, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/clipboard/paste.ts b/apps/web/src/commands/timeline/clipboard/paste.ts new file mode 100644 index 00000000..4523566e --- /dev/null +++ b/apps/web/src/commands/timeline/clipboard/paste.ts @@ -0,0 +1,193 @@ +import { + Command, + createElementSelectionResult, + type CommandResult, +} from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import type { ElementClipboardItem } from "@/clipboard"; +import { generateUUID } from "@/utils/id"; +import { + applyPlacement, + resolveTrackPlacement, + enforceMainTrackStart, +} from "@/timeline/placement"; +import { cloneAnimations } from "@/animation"; + +export class PasteCommand extends Command { + private savedState: SceneTracks | null = null; + private pastedElements: { trackId: string; elementId: string }[] = []; + private readonly time: number; + private readonly clipboardItems: ElementClipboardItem[]; + + constructor({ + time, + clipboardItems, + }: { + time: number; + clipboardItems: ElementClipboardItem[]; + }) { + super(); + this.time = time; + this.clipboardItems = clipboardItems; + } + + execute(): CommandResult | undefined { + if (this.clipboardItems.length === 0) return undefined; + + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + this.pastedElements = []; + + const minStart = Math.min( + ...this.clipboardItems.map((item) => item.element.startTime), + ); + + let updatedTracks = this.savedState; + const itemsByTrackId = groupClipboardItemsByTrackId({ + clipboardItems: this.clipboardItems, + }); + + for (const [trackId, items] of itemsByTrackId) { + const elementsToAdd = buildPastedElements({ + items, + minStart, + time: this.time, + }); + + if (elementsToAdd.length === 0) { + continue; + } + + const trackType = items[0].trackType; + const sourceTrackIndex = [ + ...updatedTracks.overlay, + updatedTracks.main, + ...updatedTracks.audio, + ].findIndex((track) => track.id === trackId); + const placementResult = resolveTrackPlacement({ + tracks: updatedTracks, + trackType, + timeSpans: elementsToAdd.map((element) => ({ + startTime: element.startTime, + duration: element.duration, + })), + strategy: { type: "aboveSource", sourceTrackIndex }, + }); + if (!placementResult) { + continue; + } + + let elementsForPlacement = elementsToAdd; + if (placementResult.kind === "existingTrack") { + const targetTrack = + placementResult.trackIndex < updatedTracks.overlay.length + ? updatedTracks.overlay[placementResult.trackIndex] + : placementResult.trackIndex === updatedTracks.overlay.length + ? updatedTracks.main + : updatedTracks.audio[ + placementResult.trackIndex - updatedTracks.overlay.length - 1 + ]; + if (targetTrack?.id === updatedTracks.main.id) { + const earliestElement = elementsToAdd.reduce((earliest, element) => + element.startTime < earliest.startTime ? element : earliest, + ); + const adjustedEarliestStartTime = enforceMainTrackStart({ + tracks: updatedTracks, + targetTrackId: targetTrack.id, + requestedStartTime: earliestElement.startTime, + }); + const delta = adjustedEarliestStartTime - earliestElement.startTime; + + if (delta !== 0) { + elementsForPlacement = elementsToAdd.map((element) => ({ + ...element, + startTime: Math.max(0, element.startTime + delta), + })); + } + } + } + + const applied = applyPlacement({ + tracks: updatedTracks, + placementResult, + elements: elementsForPlacement, + }); + if (!applied) { + continue; + } + + updatedTracks = applied.updatedTracks; + + for (const element of elementsForPlacement) { + this.pastedElements.push({ + trackId: applied.targetTrackId, + elementId: element.id, + }); + } + } + + editor.timeline.updateTracks(updatedTracks); + + if (this.pastedElements.length > 0) { + return createElementSelectionResult(this.pastedElements); + } + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getPastedElements(): { trackId: string; elementId: string }[] { + return this.pastedElements; + } +} + +function groupClipboardItemsByTrackId({ + clipboardItems, +}: { + clipboardItems: ElementClipboardItem[]; +}): Map { + const groupedItems = new Map(); + + for (const item of clipboardItems) { + const existingItems = groupedItems.get(item.trackId) ?? []; + groupedItems.set(item.trackId, [...existingItems, item]); + } + + return groupedItems; +} + +function buildPastedElements({ + items, + minStart, + time, +}: { + items: ElementClipboardItem[]; + minStart: number; + time: number; +}): TimelineElement[] { + const elementsToAdd: TimelineElement[] = []; + + for (const item of items) { + const relativeOffset = item.element.startTime - minStart; + const startTime = Math.max(0, time + relativeOffset); + const newElementId = generateUUID(); + + elementsToAdd.push({ + ...item.element, + id: newElementId, + startTime, + animations: cloneAnimations({ + animations: item.element.animations, + shouldRegenerateKeyframeIds: true, + }), + } as TimelineElement); + } + + return elementsToAdd; +} diff --git a/apps/web/src/commands/timeline/element/delete-elements.ts b/apps/web/src/commands/timeline/element/delete-elements.ts new file mode 100644 index 00000000..ad0267b7 --- /dev/null +++ b/apps/web/src/commands/timeline/element/delete-elements.ts @@ -0,0 +1,72 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks } from "@/timeline"; +import { EditorCore } from "@/core"; +import type { TimelineTrack } from "@/timeline"; + +function removeTrackElements({ + track, + elements, +}: { + track: TTrack; + elements: { trackId: string; elementId: string }[]; +}): TTrack { + const nextElements = track.elements.filter( + (element) => + !elements.some( + (target) => + target.trackId === track.id && target.elementId === element.id, + ), + ); + + return { ...track, elements: nextElements } as TTrack; +} + +export class DeleteElementsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly elements: { trackId: string; elementId: string }[]; + + constructor({ + elements, + }: { + elements: { trackId: string; elementId: string }[]; + }) { + super(); + this.elements = elements; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks: SceneTracks = { + overlay: this.savedState.overlay.map((track) => + removeTrackElements({ track, elements: this.elements }), + ), + main: removeTrackElements({ + track: this.savedState.main, + elements: this.elements, + }), + audio: this.savedState.audio.map((track) => + removeTrackElements({ track, elements: this.elements }), + ), + }; + + editor.timeline.updateTracks(updatedTracks); + + return { + selection: { + selectedElements: [], + selectedKeyframes: [], + keyframeSelectionAnchor: null, + selectedMaskPoints: null, + }, + }; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/duplicate-elements.ts b/apps/web/src/commands/timeline/element/duplicate-elements.ts new file mode 100644 index 00000000..8f65f7c7 --- /dev/null +++ b/apps/web/src/commands/timeline/element/duplicate-elements.ts @@ -0,0 +1,134 @@ +import { + Command, + createElementSelectionResult, + type CommandResult, +} from "@/commands/base-command"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { EditorCore } from "@/core"; +import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement"; +import { cloneAnimations } from "@/animation"; + +interface DuplicateElementsParams { + elements: { trackId: string; elementId: string }[]; +} + +export class DuplicateElementsCommand extends Command { + private duplicatedElements: { trackId: string; elementId: string }[] = []; + private savedState: SceneTracks | null = null; + private elements: DuplicateElementsParams["elements"]; + + constructor({ elements }: DuplicateElementsParams) { + super(); + this.elements = elements; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + this.duplicatedElements = []; + + let updatedTracks = this.savedState; + + for (const track of [ + ...this.savedState.overlay, + this.savedState.main, + ...this.savedState.audio, + ]) { + const elementsToDuplicate = this.elements.filter( + (elementEntry) => elementEntry.trackId === track.id, + ); + + if (elementsToDuplicate.length === 0) { + continue; + } + + const elementIdsToDuplicate = new Set( + elementsToDuplicate.map((element) => element.elementId), + ); + const newTrackElements: TimelineElement[] = []; + + for (const element of track.elements) { + if (!elementIdsToDuplicate.has(element.id)) { + continue; + } + + const newId = generateUUID(); + newTrackElements.push( + buildDuplicateElement({ + element, + id: newId, + startTime: element.startTime, + }), + ); + } + + const placementResult = resolveTrackPlacement({ + tracks: updatedTracks, + trackType: track.type, + timeSpans: [], + strategy: { type: "alwaysNew", position: "highest" }, + }); + if (!placementResult || placementResult.kind !== "newTrack") { + continue; + } + + const applied = applyPlacement({ + tracks: updatedTracks, + placementResult, + elements: newTrackElements, + }); + if (!applied) { + continue; + } + + updatedTracks = applied.updatedTracks; + + for (const element of newTrackElements) { + this.duplicatedElements.push({ + trackId: applied.targetTrackId, + elementId: element.id, + }); + } + } + + editor.timeline.updateTracks(updatedTracks); + + if (this.duplicatedElements.length > 0) { + return createElementSelectionResult(this.duplicatedElements); + } + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getDuplicatedElements(): { trackId: string; elementId: string }[] { + return this.duplicatedElements; + } +} + +function buildDuplicateElement({ + element, + id, + startTime, +}: { + element: TimelineElement; + id: string; + startTime: number; +}): TimelineElement { + return { + ...element, + id, + name: `${element.name} (copy)`, + startTime, + animations: cloneAnimations({ + animations: element.animations, + shouldRegenerateKeyframeIds: true, + }), + }; +} diff --git a/apps/web/src/commands/timeline/element/effects/add-effect.ts b/apps/web/src/commands/timeline/element/effects/add-effect.ts new file mode 100644 index 00000000..be08b433 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/add-effect.ts @@ -0,0 +1,75 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, VisualElement } from "@/timeline"; +import { buildDefaultEffectInstance } from "@/effects"; + +function addEffectToElement({ + element, + effectType, +}: { + element: VisualElement; + effectType: string; +}): VisualElement { + const instance = buildDefaultEffectInstance({ effectType }); + const currentEffects = element.effects ?? []; + return { ...element, effects: [...currentEffects, instance] }; +} + +export class AddClipEffectCommand extends Command { + private savedState: SceneTracks | null = null; + private effectId: string | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectType: string; + + constructor({ + trackId, + elementId, + effectType, + }: { + trackId: string; + elementId: string; + effectType: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectType = effectType; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const updated = addEffectToElement({ + element: element as VisualElement, + effectType: this.effectType, + }); + const effects = updated.effects ?? []; + this.effectId = effects[effects.length - 1]?.id ?? null; + return updated; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getEffectId(): string | null { + return this.effectId; + } +} diff --git a/apps/web/src/commands/timeline/element/effects/index.ts b/apps/web/src/commands/timeline/element/effects/index.ts new file mode 100644 index 00000000..133ce661 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/index.ts @@ -0,0 +1,5 @@ +export { AddClipEffectCommand } from "./add-effect"; +export { RemoveClipEffectCommand } from "./remove-effect"; +export { ToggleClipEffectCommand } from "./toggle-effect"; +export { UpdateClipEffectParamsCommand } from "./update-effect-params"; +export { ReorderClipEffectsCommand } from "./reorder-effect"; diff --git a/apps/web/src/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/commands/timeline/element/effects/remove-effect.ts new file mode 100644 index 00000000..e6ef9934 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/remove-effect.ts @@ -0,0 +1,66 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, VisualElement } from "@/timeline"; + +function removeEffectFromElement({ + element, + effectId, +}: { + element: VisualElement; + effectId: string; +}): VisualElement { + const currentEffects = element.effects ?? []; + const filtered = currentEffects.filter((effect) => effect.id !== effectId); + return { ...element, effects: filtered }; +} + +export class RemoveClipEffectCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + + constructor({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return removeEffectFromElement({ + element: element as VisualElement, + effectId: this.effectId, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/commands/timeline/element/effects/reorder-effect.ts new file mode 100644 index 00000000..554804e2 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/reorder-effect.ts @@ -0,0 +1,74 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, VisualElement } from "@/timeline"; + +function reorderEffectsOnElement({ + element, + fromIndex, + toIndex, +}: { + element: VisualElement; + fromIndex: number; + toIndex: number; +}): VisualElement { + const effects = [...(element.effects ?? [])]; + const [moved] = effects.splice(fromIndex, 1); + effects.splice(toIndex, 0, moved); + return { ...element, effects }; +} + +export class ReorderClipEffectsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly fromIndex: number; + private readonly toIndex: number; + + constructor({ + trackId, + elementId, + fromIndex, + toIndex, + }: { + trackId: string; + elementId: string; + fromIndex: number; + toIndex: number; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.fromIndex = fromIndex; + this.toIndex = toIndex; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return reorderEffectsOnElement({ + element: element as VisualElement, + fromIndex: this.fromIndex, + toIndex: this.toIndex, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/commands/timeline/element/effects/toggle-effect.ts new file mode 100644 index 00000000..89149a79 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/toggle-effect.ts @@ -0,0 +1,68 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, VisualElement } from "@/timeline"; + +export function toggleEffectOnElement({ + element, + effectId, +}: { + element: VisualElement; + effectId: string; +}): VisualElement { + const currentEffects = element.effects ?? []; + const updated = currentEffects.map((effect) => + effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect, + ); + return { ...element, effects: updated }; +} + +export class ToggleClipEffectCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + + constructor({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return toggleEffectOnElement({ + element: element as VisualElement, + effectId: this.effectId, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/commands/timeline/element/effects/update-effect-params.ts new file mode 100644 index 00000000..62b3c937 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/update-effect-params.ts @@ -0,0 +1,87 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/timeline"; +import type { ParamValues } from "@/params"; +import type { SceneTracks, VisualElement } from "@/timeline"; + +function updateEffectParamsOnElement({ + element, + effectId, + params, +}: { + element: VisualElement; + effectId: string; + params: Partial; +}): VisualElement { + const currentEffects = element.effects ?? []; + const updated = currentEffects.map((effect) => { + if (effect.id !== effectId) { + return effect; + } + + const nextParams = { ...effect.params }; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + nextParams[key] = value; + } + } + + return { ...effect, params: nextParams }; + }); + return { ...element, effects: updated }; +} + +export class UpdateClipEffectParamsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly params: Partial; + + constructor({ + trackId, + elementId, + effectId, + params, + }: { + trackId: string; + elementId: string; + effectId: string; + params: Partial; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.params = params; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return updateEffectParamsOnElement({ + element: element as VisualElement, + effectId: this.effectId, + params: this.params, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/index.ts b/apps/web/src/commands/timeline/element/index.ts new file mode 100644 index 00000000..ff365801 --- /dev/null +++ b/apps/web/src/commands/timeline/element/index.ts @@ -0,0 +1,11 @@ +export { InsertElementCommand } from "./insert-element"; +export { DeleteElementsCommand } from "./delete-elements"; +export { DuplicateElementsCommand } from "./duplicate-elements"; +export { SplitElementsCommand } from "./split-elements"; +export { UpdateElementsCommand } from "./update-elements"; +export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation"; +export { MoveElementCommand } from "./move-elements"; + +export * from "./keyframes"; +export * from "./effects"; +export * from "./masks"; diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts new file mode 100644 index 00000000..49ac6f2d --- /dev/null +++ b/apps/web/src/commands/timeline/element/insert-element.ts @@ -0,0 +1,291 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { + CreateTimelineElement, + SceneTracks, + TimelineElement, + TrackType, +} from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { requiresMediaId } from "@/timeline/element-utils"; +import type { MediaAsset } from "@/media/types"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; +import { floatToFrameRate } from "@/fps/utils"; +import { graphicsRegistry, registerDefaultGraphics } from "@/graphics"; +import { + applyPlacement, + canElementGoOnTrack, + resolveTrackPlacement, + validateElementTrackCompatibility, +} from "@/timeline/placement"; + +type InsertElementPlacement = + | { mode: "explicit"; trackId: string } + | { mode: "auto"; trackType?: TrackType; insertIndex?: number }; + +export interface InsertElementParams { + element: CreateTimelineElement; + placement: InsertElementPlacement; +} + +export class InsertElementCommand extends Command { + private elementId: string; + private savedState: SceneTracks | null = null; + private targetTrackId: string | null = null; + + constructor({ element, placement }: InsertElementParams) { + super(); + this.elementId = generateUUID(); + this.element = element; + this.placement = placement; + } + + private element: CreateTimelineElement; + private placement: InsertElementPlacement; + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + if (!this.validateElementBasics({ element: this.element })) { + return; + } + + const totalElementsInTimeline = + this.savedState.main.elements.length + + this.savedState.overlay.reduce( + (total, track) => total + track.elements.length, + 0, + ) + + this.savedState.audio.reduce( + (total, track) => total + track.elements.length, + 0, + ); + const isFirstElement = totalElementsInTimeline === 0; + + const newElement = this.buildElement({ element: this.element }); + const updateResult = this.applyPlacementResult({ + tracks: this.savedState, + element: newElement, + }); + + if (!updateResult) { + return; + } + + const { updatedTracks, targetTrackId } = updateResult; + this.targetTrackId = targetTrackId; + + const isVisualMedia = + newElement.type === "video" || newElement.type === "image"; + + if (isFirstElement && isVisualMedia) { + const mediaAssets = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + const asset = mediaAssets.find( + (item: MediaAsset) => item.id === newElement.mediaId, + ); + + if (asset?.width && asset?.height) { + const nextCanvasSize = { width: asset.width, height: asset.height }; + const shouldSetOriginalCanvasSize = + !activeProject?.settings.originalCanvasSize; + editor.project.updateSettings({ + settings: { + canvasSize: nextCanvasSize, + ...(shouldSetOriginalCanvasSize + ? { originalCanvasSize: nextCanvasSize } + : {}), + }, + pushHistory: false, + }); + } + + if (asset?.type === "video" && asset?.fps) { + editor.project.updateSettings({ + settings: { fps: floatToFrameRate(asset.fps) }, + pushHistory: false, + }); + } + } + + editor.timeline.updateTracks(updatedTracks); + + return { + selection: { + selectedElements: [ + { trackId: targetTrackId, elementId: this.elementId }, + ], + selectedKeyframes: [], + keyframeSelectionAnchor: null, + selectedMaskPoints: null, + }, + }; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getElementId(): string { + return this.elementId; + } + + getTrackId(): string | null { + return this.targetTrackId; + } + + private buildElement({ + element, + }: { + element: CreateTimelineElement; + }): TimelineElement { + return { + ...element, + id: this.elementId, + startTime: element.startTime, + trimStart: element.trimStart ?? 0, + trimEnd: element.trimEnd ?? 0, + duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION, + } as TimelineElement; + } + + private validateElementBasics({ + element, + }: { + element: CreateTimelineElement; + }): boolean { + if (requiresMediaId({ element }) && !("mediaId" in element)) { + console.error("Element requires mediaId"); + return false; + } + + if ( + element.type === "audio" && + element.sourceType === "library" && + !element.sourceUrl + ) { + console.error("Library audio element must have sourceUrl"); + return false; + } + + if (element.type === "sticker" && !element.stickerId) { + console.error("Sticker element must have stickerId"); + return false; + } + + if (element.type === "graphic") { + registerDefaultGraphics(); + if ( + !element.definitionId || + !graphicsRegistry.has(element.definitionId) + ) { + console.error("Graphic element must have a valid definitionId"); + return false; + } + } + + if (element.type === "text" && !element.content) { + console.error("Text element must have content"); + return false; + } + + if (element.type === "effect" && !element.effectType) { + console.error("Effect element must have effectType"); + return false; + } + + return true; + } + + private applyPlacementResult({ + tracks, + element, + }: { + tracks: SceneTracks; + element: TimelineElement; + }): { updatedTracks: SceneTracks; targetTrackId: string } | null { + const placement = this.placement; + + if ( + placement.mode === "auto" && + placement.trackType && + !canElementGoOnTrack({ + elementType: element.type, + trackType: placement.trackType, + }) + ) { + console.error( + `${element.type} elements cannot be placed on ${placement.trackType} tracks`, + ); + return null; + } + + const placementResult = resolveTrackPlacement({ + tracks, + ...(placement.mode === "auto" && placement.trackType + ? { trackType: placement.trackType } + : { elementType: element.type }), + timeSpans: [ + { + startTime: element.startTime, + duration: element.duration, + }, + ], + strategy: + placement.mode === "explicit" + ? { type: "explicit", trackId: placement.trackId } + : { type: "firstAvailable" }, + }); + if (!placementResult) { + if (placement.mode === "explicit") { + const targetTrack = + tracks.main.id === placement.trackId + ? tracks.main + : (tracks.overlay.find((track) => track.id === placement.trackId) ?? + tracks.audio.find((track) => track.id === placement.trackId)); + if (!targetTrack) { + console.error("Track not found:", placement.trackId); + return null; + } + + const validation = validateElementTrackCompatibility({ + element, + track: targetTrack, + }); + console.error(validation.errorMessage); + } + + return null; + } + + const elementToPlace = + placementResult.kind === "existingTrack" + ? { + ...element, + startTime: placementResult.adjustedStartTime ?? element.startTime, + } + : element; + + const appliedPlacement = applyPlacement({ + tracks, + placementResult, + elements: [elementToPlace], + newTrackInsertIndexOverride: + placement.mode === "auto" && typeof placement.insertIndex === "number" + ? placement.insertIndex + : undefined, + }); + if (!appliedPlacement) { + return null; + } + + return { + updatedTracks: appliedPlacement.updatedTracks, + targetTrackId: appliedPlacement.targetTrackId, + }; + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/index.ts b/apps/web/src/commands/timeline/element/keyframes/index.ts new file mode 100644 index 00000000..319a827b --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/index.ts @@ -0,0 +1,6 @@ +export * from "./remove-effect-param-keyframe"; +export * from "./remove-keyframe"; +export * from "./retime-keyframe"; +export * from "./update-scalar-keyframe-curve"; +export * from "./upsert-effect-param-keyframe"; +export * from "./upsert-keyframe"; diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts new file mode 100644 index 00000000..c049d5bb --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts @@ -0,0 +1,69 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { removeEffectParamKeyframe } from "@/animation/effect-param-channel"; +import { updateElementInSceneTracks } from "@/timeline"; +import { isVisualElement } from "@/timeline/element-utils"; +import type { SceneTracks } from "@/timeline"; + +export class RemoveEffectParamKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly paramKey: string; + private readonly keyframeId: string; + + constructor({ + trackId, + elementId, + effectId, + paramKey, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + keyframeId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.paramKey = paramKey; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const animations = removeEffectParamKeyframe({ + animations: element.animations, + effectId: this.effectId, + paramKey: this.paramKey, + keyframeId: this.keyframeId, + }); + return { ...element, animations }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts new file mode 100644 index 00000000..ed917406 --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts @@ -0,0 +1,107 @@ +import { EditorCore } from "@/core"; +import { + hasKeyframesForPath, + removeElementKeyframe, + resolveAnimationTarget, +} from "@/animation"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { updateElementInSceneTracks } from "@/timeline"; +import type { AnimationPath, AnimationValue } from "@/animation/types"; +import type { SceneTracks, TimelineElement } from "@/timeline"; + +function removeKeyframeAndPersist({ + element, + propertyPath, + keyframeId, + valueAtPlayhead, +}: { + element: TimelineElement; + propertyPath: AnimationPath; + keyframeId: string; + valueAtPlayhead: AnimationValue | null; +}): TimelineElement { + const target = resolveAnimationTarget({ element, path: propertyPath }); + if (!target) { + return element; + } + + const nextAnimations = removeElementKeyframe({ + animations: element.animations, + propertyPath, + keyframeId, + }); + + const isChannelNowEmpty = !hasKeyframesForPath({ + animations: nextAnimations, + propertyPath, + }); + + // When clearing all keyframes, preserve the value at the playhead (what the user was seeing) + const shouldPersistToBase = isChannelNowEmpty && valueAtPlayhead !== null; + + const baseElement = shouldPersistToBase + ? target.setBaseValue({ value: valueAtPlayhead }) + : element; + + return { ...baseElement, animations: nextAnimations }; +} + +export class RemoveKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly keyframeId: string; + private readonly valueAtPlayhead: AnimationValue | null; + + constructor({ + trackId, + elementId, + propertyPath, + keyframeId, + valueAtPlayhead, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; + valueAtPlayhead: AnimationValue | null; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.keyframeId = keyframeId; + this.valueAtPlayhead = valueAtPlayhead; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => + removeKeyframeAndPersist({ + element, + propertyPath: this.propertyPath, + keyframeId: this.keyframeId, + valueAtPlayhead: this.valueAtPlayhead, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts new file mode 100644 index 00000000..ccbdbc5d --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts @@ -0,0 +1,75 @@ +import { EditorCore } from "@/core"; +import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { updateElementInSceneTracks } from "@/timeline"; +import type { AnimationPath } from "@/animation/types"; +import type { SceneTracks } from "@/timeline"; + +export class RetimeKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly keyframeId: string; + private readonly nextTime: number; + + constructor({ + trackId, + elementId, + propertyPath, + keyframeId, + nextTime, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; + nextTime: number; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.keyframeId = keyframeId; + this.nextTime = nextTime; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => { + if (!resolveAnimationTarget({ element, path: this.propertyPath })) { + return element; + } + + const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + return { + ...element, + animations: retimeElementKeyframe({ + animations: element.animations, + propertyPath: this.propertyPath, + keyframeId: this.keyframeId, + time: boundedTime, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts new file mode 100644 index 00000000..4a3db39d --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts @@ -0,0 +1,85 @@ +import { EditorCore } from "@/core"; +import { + resolveAnimationTarget, + updateScalarKeyframeCurve, +} from "@/animation"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { updateElementInSceneTracks } from "@/timeline"; +import type { + AnimationPath, + ScalarCurveKeyframePatch, +} from "@/animation/types"; +import type { SceneTracks } from "@/timeline"; + +export class UpdateScalarKeyframeCurveCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly componentKey: string; + private readonly keyframeId: string; + private readonly patch: ScalarCurveKeyframePatch; + + constructor({ + trackId, + elementId, + propertyPath, + componentKey, + keyframeId, + patch, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; + patch: ScalarCurveKeyframePatch; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.componentKey = componentKey; + this.keyframeId = keyframeId; + this.patch = patch; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => { + if (!resolveAnimationTarget({ element, path: this.propertyPath })) { + return element; + } + + return { + ...element, + animations: updateScalarKeyframeCurve({ + animations: element.animations, + propertyPath: this.propertyPath, + componentKey: this.componentKey, + keyframeId: this.keyframeId, + patch: this.patch, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts new file mode 100644 index 00000000..eae8f039 --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -0,0 +1,104 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { + buildEffectParamPath, + resolveAnimationTarget, + upsertPathKeyframe, +} from "@/animation"; +import { updateElementInSceneTracks } from "@/timeline"; +import { isVisualElement } from "@/timeline/element-utils"; +import type { AnimationInterpolation } from "@/animation/types"; +import type { SceneTracks } from "@/timeline"; + +export class UpsertEffectParamKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly paramKey: string; + private readonly time: number; + private readonly value: number | string | boolean; + private readonly interpolation: AnimationInterpolation | undefined; + private readonly keyframeId: string | undefined; + + constructor({ + trackId, + elementId, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + time: number; + value: number | string | boolean; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.paramKey = paramKey; + this.time = time; + this.value = value; + this.interpolation = interpolation; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const propertyPath = buildEffectParamPath({ + effectId: this.effectId, + paramKey: this.paramKey, + }); + const target = resolveAnimationTarget({ + element, + path: propertyPath, + }); + if (!target) { + return element; + } + + const animations = upsertPathKeyframe({ + animations: element.animations, + propertyPath, + time: boundedTime, + value: this.value, + interpolation: this.interpolation, + keyframeId: this.keyframeId, + kind: target.kind, + defaultInterpolation: target.defaultInterpolation, + coerceValue: target.coerceValue, + }); + return { ...element, animations }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts new file mode 100644 index 00000000..30514146 --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -0,0 +1,96 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation"; +import { updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks } from "@/timeline"; +import type { + AnimationPath, + AnimationInterpolation, + AnimationValue, +} from "@/animation/types"; + +export class UpsertKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly time: number; + private readonly value: AnimationValue; + private readonly interpolation: AnimationInterpolation | undefined; + private readonly keyframeId: string | undefined; + + constructor({ + trackId, + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.time = time; + this.value = value; + this.interpolation = interpolation; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => { + const target = resolveAnimationTarget({ + element, + path: this.propertyPath, + }); + if (!target) { + return element; + } + + const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + return { + ...element, + animations: upsertPathKeyframe({ + animations: element.animations, + propertyPath: this.propertyPath, + time: boundedTime, + value: this.value, + interpolation: this.interpolation, + keyframeId: this.keyframeId, + kind: target.kind, + defaultInterpolation: target.defaultInterpolation, + coerceValue: target.coerceValue, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts new file mode 100644 index 00000000..1445b1f8 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts @@ -0,0 +1,131 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { + getCustomMaskClosedStateAfterPointRemoval, + removeCustomMaskPoints, +} from "@/masks/custom-path"; +import type { CustomMask } from "@/masks/types"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { MaskableElement, SceneTracks } from "@/timeline"; + +function deletePointsFromCustomMask({ + mask, + pointIds, +}: { + mask: CustomMask; + pointIds: string[]; +}): CustomMask { + const points = mask.params.path; + const nextPoints = removeCustomMaskPoints({ points, pointIds }); + if (nextPoints.length === points.length) { + return mask; + } + + return { + ...mask, + params: { + ...mask.params, + path: nextPoints, + closed: getCustomMaskClosedStateAfterPointRemoval({ + wasClosed: mask.params.closed, + remainingPointCount: nextPoints.length, + }), + }, + }; +} + +function deletePointsFromElementMask({ + element, + maskId, + pointIds, +}: { + element: MaskableElement; + maskId: string; + pointIds: string[]; +}): { element: MaskableElement; didDeletePoints: boolean } { + const currentMasks = element.masks ?? []; + let didDeletePoints = false; + const nextMasks = currentMasks.map((mask) => { + if (mask.id !== maskId || mask.type !== "custom") { + return mask; + } + + const nextMask = deletePointsFromCustomMask({ + mask, + pointIds, + }); + didDeletePoints ||= nextMask !== mask; + return nextMask; + }); + + return { + element: didDeletePoints ? { ...element, masks: nextMasks } : element, + didDeletePoints, + }; +} + +export class DeleteCustomMaskPointsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + private readonly pointIds: string[]; + + constructor({ + trackId, + elementId, + maskId, + pointIds, + }: { + trackId: string; + elementId: string; + maskId: string; + pointIds: string[]; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + this.pointIds = pointIds; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + let didDeletePoints = false; + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => { + const result = deletePointsFromElementMask({ + element: element as MaskableElement, + maskId: this.maskId, + pointIds: this.pointIds, + }); + didDeletePoints ||= result.didDeletePoints; + return result.element; + }, + }); + + if (didDeletePoints) { + editor.timeline.updateTracks(updatedTracks); + return { + selection: { + selectedMaskPoints: null, + }, + }; + } + + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/masks/index.ts b/apps/web/src/commands/timeline/element/masks/index.ts new file mode 100644 index 00000000..dd6373b1 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/index.ts @@ -0,0 +1,4 @@ +export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points"; +export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point"; +export { RemoveMaskCommand } from "./remove-mask"; +export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts new file mode 100644 index 00000000..58559af6 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts @@ -0,0 +1,169 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom"; +import type { ElementBounds } from "@/preview/element-bounds"; +import type { CustomMask } from "@/masks/types"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { MaskableElement, SceneTracks } from "@/timeline"; + +function insertPointIntoCustomMask({ + mask, + segmentIndex, + canvasPoint, + bounds, +}: { + mask: CustomMask; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; +}): { mask: CustomMask; insertedPointId: string | null } { + const result = insertPointOnCustomMaskSegment({ + params: mask.params, + segmentIndex, + canvasPoint, + bounds, + }); + if (!result) { + return { + mask, + insertedPointId: null, + }; + } + + return { + mask: { + ...mask, + params: result.params, + }, + insertedPointId: result.pointId, + }; +} + +function insertPointIntoElementMask({ + element, + maskId, + segmentIndex, + canvasPoint, + bounds, +}: { + element: MaskableElement; + maskId: string; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; +}): { + element: MaskableElement; + didInsertPoint: boolean; + insertedPointId: string | null; +} { + const currentMasks = element.masks ?? []; + let insertedPointId: string | null = null; + let didInsertPoint = false; + + const nextMasks = currentMasks.map((mask) => { + if (mask.id !== maskId || mask.type !== "custom") { + return mask; + } + + const result = insertPointIntoCustomMask({ + mask, + segmentIndex, + canvasPoint, + bounds, + }); + if (result.insertedPointId) { + insertedPointId = result.insertedPointId; + didInsertPoint = true; + } + return result.mask; + }); + + return { + element: didInsertPoint ? { ...element, masks: nextMasks } : element, + didInsertPoint, + insertedPointId, + }; +} + +export class InsertCustomMaskPointCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + private readonly segmentIndex: number; + private readonly canvasPoint: { x: number; y: number }; + private readonly bounds: ElementBounds; + + constructor({ + trackId, + elementId, + maskId, + segmentIndex, + canvasPoint, + bounds, + }: { + trackId: string; + elementId: string; + maskId: string; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + this.segmentIndex = segmentIndex; + this.canvasPoint = canvasPoint; + this.bounds = bounds; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + let didInsertPoint = false; + let insertedPointId: string | null = null; + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => { + const result = insertPointIntoElementMask({ + element: element as MaskableElement, + maskId: this.maskId, + segmentIndex: this.segmentIndex, + canvasPoint: this.canvasPoint, + bounds: this.bounds, + }); + didInsertPoint ||= result.didInsertPoint; + insertedPointId ??= result.insertedPointId; + return result.element; + }, + }); + + if (!didInsertPoint || !insertedPointId) { + return undefined; + } + + editor.timeline.updateTracks(updatedTracks); + return { + selection: { + selectedMaskPoints: { + trackId: this.trackId, + elementId: this.elementId, + maskId: this.maskId, + pointIds: [insertedPointId], + }, + }, + }; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/commands/timeline/element/masks/remove-mask.ts new file mode 100644 index 00000000..b543bce1 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/remove-mask.ts @@ -0,0 +1,65 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, MaskableElement } from "@/timeline"; + +function removeMaskFromElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId); + return { ...element, masks: filteredMasks }; +} + +export class RemoveMaskCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + removeMaskFromElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts new file mode 100644 index 00000000..fec9b923 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -0,0 +1,76 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { Mask } from "@/masks/types"; +import type { SceneTracks, MaskableElement } from "@/timeline"; + +export function toggleMaskInvertedOnElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const toggleMask = (mask: TMask): TMask => ({ + ...mask, + params: { + ...mask.params, + inverted: !mask.params.inverted, + }, + }); + const updatedMasks = currentMasks.map((mask) => + mask.id !== maskId ? mask : toggleMask(mask), + ); + + return { ...element, masks: updatedMasks }; +} + +export class ToggleMaskInvertedCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + toggleMaskInvertedOnElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/move-elements.ts b/apps/web/src/commands/timeline/element/move-elements.ts new file mode 100644 index 00000000..3ed31d42 --- /dev/null +++ b/apps/web/src/commands/timeline/element/move-elements.ts @@ -0,0 +1,183 @@ +import { + Command, + createElementSelectionResult, + type CommandResult, +} from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline"; +import { + buildEmptyTrack, + validateElementTrackCompatibility, +} from "@/timeline/placement"; +import type { + PlannedElementMove, + PlannedTrackCreation, +} from "@/timeline/group-move"; +import { findTrackInSceneTracks } from "@/timeline/track-element-update"; + +export class MoveElementCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor({ + moves, + createTracks = [], + }: { + moves: PlannedElementMove[]; + createTracks?: PlannedTrackCreation[]; + }) { + super(); + this.moves = moves; + this.createTracks = createTracks; + } + + private readonly moves: PlannedElementMove[]; + private readonly createTracks: PlannedTrackCreation[]; + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + let tracksToUpdate = this.savedState; + for (const createTrack of [...this.createTracks].sort( + (firstTrack, secondTrack) => firstTrack.index - secondTrack.index, + )) { + tracksToUpdate = insertTrackAtDisplayIndex({ + tracks: tracksToUpdate, + track: buildEmptyTrack({ + id: createTrack.id, + type: createTrack.type, + }), + insertIndex: createTrack.index, + }); + } + + const movedElementsById = new Map(); + for (const move of this.moves) { + const sourceTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: move.sourceTrackId, + }); + const sourceElement = sourceTrack?.elements.find( + (trackElement) => trackElement.id === move.elementId, + ); + if (!sourceTrack || !sourceElement) { + throw new Error("Source track or element not found"); + } + + const targetTrack = findTrackInSceneTracks({ + tracks: tracksToUpdate, + trackId: move.targetTrackId, + }); + if (!targetTrack) { + throw new Error("Target track not found"); + } + + const validation = validateElementTrackCompatibility({ + element: sourceElement, + track: targetTrack, + }); + if (!validation.isValid) { + throw new Error(validation.errorMessage); + } + + movedElementsById.set(move.elementId, { + ...sourceElement, + startTime: move.newStartTime, + }); + } + + const movedElementIds = new Set(this.moves.map((move) => move.elementId)); + const movedElementsByTargetTrackId = new Map(); + for (const move of this.moves) { + const movedElement = movedElementsById.get(move.elementId); + if (!movedElement) { + continue; + } + + const nextTargetElements = + movedElementsByTargetTrackId.get(move.targetTrackId) ?? []; + nextTargetElements.push(movedElement); + movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements); + } + + const updatedTracks = mapSceneTracks({ + tracks: tracksToUpdate, + update: (track) => ({ + ...track, + elements: [ + ...track.elements.filter( + (element) => !movedElementIds.has(element.id), + ), + ...(movedElementsByTargetTrackId.get(track.id) ?? []), + ], + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return createElementSelectionResult( + this.moves.map(({ elementId, targetTrackId }) => ({ + trackId: targetTrackId, + elementId, + })), + ); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} + +function mapSceneTracks({ + tracks, + update, +}: { + tracks: SceneTracks; + update: (track: TTrack) => TTrack; +}): SceneTracks { + return { + overlay: tracks.overlay.map((track) => update(track)), + main: update(tracks.main), + audio: tracks.audio.map((track) => update(track)), + }; +} + +function insertTrackAtDisplayIndex({ + tracks, + track, + insertIndex, +}: { + tracks: SceneTracks; + track: TimelineTrack; + insertIndex: number; +}): SceneTracks { + if (track.type === "audio") { + const audioInsertIndex = Math.max( + 0, + Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length), + ); + return { + ...tracks, + audio: [ + ...tracks.audio.slice(0, audioInsertIndex), + track, + ...tracks.audio.slice(audioInsertIndex), + ], + }; + } + + const overlayInsertIndex = Math.max( + 0, + Math.min(insertIndex, tracks.overlay.length), + ); + return { + ...tracks, + overlay: [ + ...tracks.overlay.slice(0, overlayInsertIndex), + track, + ...tracks.overlay.slice(overlayInsertIndex), + ], + }; +} diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts new file mode 100644 index 00000000..11c317f4 --- /dev/null +++ b/apps/web/src/commands/timeline/element/split-elements.ts @@ -0,0 +1,182 @@ +import { + Command, + createElementSelectionResult, + type CommandResult, +} from "@/commands/base-command"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { EditorCore } from "@/core"; +import { isRetimableElement } from "@/timeline"; +import { splitAnimationsAtTime } from "@/animation"; +import { getSourceSpanAtClipTime } from "@/retime"; + +export class SplitElementsCommand extends Command { + private savedState: SceneTracks | null = null; + private rightSideElements: { trackId: string; elementId: string }[] = []; + private readonly elements: { trackId: string; elementId: string }[]; + private readonly splitTime: number; + private readonly retainSide: "both" | "left" | "right"; + + constructor({ + elements, + splitTime, + retainSide = "both", + }: { + elements: { trackId: string; elementId: string }[]; + splitTime: number; + retainSide?: "both" | "left" | "right"; + }) { + super(); + this.elements = elements; + this.splitTime = splitTime; + this.retainSide = retainSide; + } + + getRightSideElements(): { trackId: string; elementId: string }[] { + return this.rightSideElements; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + this.rightSideElements = []; + + const splitTrack = < + TTrack extends { id: string; elements: TimelineElement[] }, + >( + track: TTrack, + ): TTrack => { + const elementsToSplit = this.elements.filter( + (target) => target.trackId === track.id, + ); + + if (elementsToSplit.length === 0) { + return track; + } + + const elements = track.elements.flatMap((element) => { + const shouldSplit = elementsToSplit.some( + (target) => target.elementId === element.id, + ); + + if (!shouldSplit) { + return [element]; + } + + const effectiveStart = element.startTime; + const effectiveEnd = element.startTime + element.duration; + + if ( + this.splitTime <= effectiveStart || + this.splitTime >= effectiveEnd + ) { + return [element]; + } + + 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, + shouldIncludeSplitBoundary: true, + }); + let splitResult: TimelineElement[]; + + if (this.retainSide === "left") { + splitResult = [ + { + ...element, + duration: leftVisibleDuration, + trimEnd: element.trimEnd + rightSourceSpan, + name: `${element.name} (left)`, + animations: leftAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), + }, + ]; + } else if (this.retainSide === "right") { + const newId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: newId, + }); + splitResult = [ + { + ...element, + id: newId, + startTime: this.splitTime, + duration: rightVisibleDuration, + trimStart: element.trimStart + leftSourceSpan, + name: `${element.name} (right)`, + animations: rightAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), + }, + ]; + } else { + // "both" - split into two pieces + const secondElementId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: secondElementId, + }); + splitResult = [ + { + ...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 } : {}), + }, + ]; + } + + return splitResult; + }); + + return { ...track, elements } as TTrack; + }; + + const updatedTracks: SceneTracks = { + overlay: this.savedState.overlay.map((track) => splitTrack(track)), + main: splitTrack(this.savedState.main), + audio: this.savedState.audio.map((track) => splitTrack(track)), + }; + + editor.timeline.updateTracks(updatedTracks); + + if (this.rightSideElements.length > 0) { + return createElementSelectionResult(this.rightSideElements); + } + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts new file mode 100644 index 00000000..36d4cd87 --- /dev/null +++ b/apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts @@ -0,0 +1,145 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { + buildSeparatedAudioElement, + canExtractSourceAudio, + isSourceAudioSeparated, +} from "@/timeline/audio-separation"; +import { + applyPlacement, + resolveTrackPlacement, +} from "@/timeline/placement"; +import { updateElementInSceneTracks } from "@/timeline/track-element-update"; +import type { + SceneTracks, + TimelineElement, + VideoElement, +} from "@/timeline/types"; +import { generateUUID } from "@/utils/id"; + +export class ToggleSourceAudioSeparationCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor( + private readonly params: { + trackId: string; + elementId: string; + }, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const sourceTrack = [ + ...this.savedState.overlay, + this.savedState.main, + ...this.savedState.audio, + ].find((track) => track.id === this.params.trackId); + if (!sourceTrack) { + return; + } + const sourceElement = sourceTrack.elements.find( + (element) => element.id === this.params.elementId, + ) as TimelineElement | undefined; + if (!sourceElement || sourceElement.type !== "video") { + return; + } + const videoElement: VideoElement = sourceElement; + + if (isSourceAudioSeparated({ element: videoElement })) { + editor.timeline.updateTracks( + updateSourceAudioEnabled({ + tracks: this.savedState, + trackId: this.params.trackId, + elementId: this.params.elementId, + isSourceAudioEnabled: true, + }), + ); + return; + } + + const mediaAsset = editor.media + .getAssets() + .find((asset) => asset.id === videoElement.mediaId); + if (!canExtractSourceAudio(videoElement, mediaAsset)) { + return; + } + if (videoElement.duration <= 0) { + return; + } + + const separatedAudioElement = { + ...buildSeparatedAudioElement({ + sourceElement: videoElement, + }), + id: generateUUID(), + }; + const placementResult = resolveTrackPlacement({ + tracks: this.savedState, + trackType: "audio", + timeSpans: [ + { + startTime: separatedAudioElement.startTime, + duration: separatedAudioElement.duration, + }, + ], + strategy: { type: "firstAvailable" }, + }); + if (!placementResult) { + return; + } + const appliedPlacement = applyPlacement({ + tracks: this.savedState, + placementResult, + elements: [separatedAudioElement], + }); + if (!appliedPlacement) { + return; + } + + editor.timeline.updateTracks( + updateSourceAudioEnabled({ + tracks: appliedPlacement.updatedTracks, + trackId: this.params.trackId, + elementId: this.params.elementId, + isSourceAudioEnabled: false, + }), + ); + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} + +function updateSourceAudioEnabled({ + tracks, + trackId, + elementId, + isSourceAudioEnabled, +}: { + tracks: SceneTracks; + trackId: string; + elementId: string; + isSourceAudioEnabled: boolean; +}): SceneTracks { + return updateElementInSceneTracks({ + tracks, + trackId, + elementId, + elementPredicate: (element): element is VideoElement => + element.type === "video", + update: (element) => ({ + ...element, + isSourceAudioEnabled, + }), + }); +} diff --git a/apps/web/src/commands/timeline/element/update-elements.ts b/apps/web/src/commands/timeline/element/update-elements.ts new file mode 100644 index 00000000..f03f2498 --- /dev/null +++ b/apps/web/src/commands/timeline/element/update-elements.ts @@ -0,0 +1,75 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import { + findTrackInSceneTracks, + updateElementInSceneTracks, +} from "@/timeline"; +import { applyElementUpdate } from "@/timeline/update-pipeline"; + +export class UpdateElementsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly updates: Array<{ + trackId: string; + elementId: string; + patch: Partial; + }>; + + constructor({ + updates, + }: { + updates: Array<{ + trackId: string; + elementId: string; + patch: Partial; + }>; + }) { + super(); + this.updates = updates; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + let updatedTracks = this.savedState; + + for (const updateEntry of this.updates) { + const currentTrack = findTrackInSceneTracks({ + tracks: updatedTracks, + trackId: updateEntry.trackId, + }); + const currentElement = currentTrack?.elements.find( + (element) => element.id === updateEntry.elementId, + ); + if (!currentTrack || !currentElement) { + continue; + } + + const nextElement = applyElementUpdate({ + element: currentElement, + patch: updateEntry.patch, + context: { + tracks: updatedTracks, + trackId: updateEntry.trackId, + }, + }); + + updatedTracks = updateElementInSceneTracks({ + tracks: updatedTracks, + trackId: updateEntry.trackId, + elementId: updateEntry.elementId, + update: () => nextElement, + }); + } + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/index.ts b/apps/web/src/commands/timeline/index.ts new file mode 100644 index 00000000..cb285d94 --- /dev/null +++ b/apps/web/src/commands/timeline/index.ts @@ -0,0 +1,5 @@ +export * from "./track"; +export * from "./element"; +export * from "./clipboard"; + +export { TracksSnapshotCommand } from "./tracks-snapshot"; diff --git a/apps/web/src/commands/timeline/track/add-track.ts b/apps/web/src/commands/timeline/track/add-track.ts new file mode 100644 index 00000000..6b58b687 --- /dev/null +++ b/apps/web/src/commands/timeline/track/add-track.ts @@ -0,0 +1,115 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks, TrackType } from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { EditorCore } from "@/core"; +import { + buildEmptyTrack, + getDefaultInsertIndexForTrack, +} from "@/timeline/placement"; + +export class AddTrackCommand extends Command { + private trackId: string; + private savedState: SceneTracks | null = null; + + constructor( + private type: TrackType, + private index?: number, + ) { + super(); + this.trackId = generateUUID(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const insertIndex = + this.index ?? + getDefaultInsertIndexForTrack({ + tracks: this.savedState, + trackType: this.type, + }); + + const updatedTracks = + this.type === "audio" + ? buildAudioTrackState({ + tracks: this.savedState, + insertIndex, + trackId: this.trackId, + }) + : buildOverlayTrackState({ + tracks: this.savedState, + insertIndex, + trackId: this.trackId, + trackType: this.type, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getTrackId(): string { + return this.trackId; + } +} + +function buildAudioTrackState({ + tracks, + insertIndex, + trackId, +}: { + tracks: SceneTracks; + insertIndex: number; + trackId: string; +}): SceneTracks { + const audioInsertIndex = Math.max(0, insertIndex - tracks.overlay.length - 1); + const newTrack = buildEmptyTrack({ + id: trackId, + type: "audio", + }); + return { + ...tracks, + audio: [ + ...tracks.audio.slice(0, audioInsertIndex), + newTrack, + ...tracks.audio.slice(audioInsertIndex), + ], + }; +} + +function buildOverlayTrackState({ + tracks, + insertIndex, + trackId, + trackType, +}: { + tracks: SceneTracks; + insertIndex: number; + trackId: string; + trackType: Exclude; +}): SceneTracks { + const overlayInsertIndex = Math.min(insertIndex, tracks.overlay.length); + const newTrack = + trackType === "video" + ? buildEmptyTrack({ id: trackId, type: "video" }) + : trackType === "text" + ? buildEmptyTrack({ id: trackId, type: "text" }) + : trackType === "graphic" + ? buildEmptyTrack({ id: trackId, type: "graphic" }) + : buildEmptyTrack({ id: trackId, type: "effect" }); + return { + ...tracks, + overlay: [ + ...tracks.overlay.slice(0, overlayInsertIndex), + newTrack, + ...tracks.overlay.slice(overlayInsertIndex), + ], + }; +} diff --git a/apps/web/src/commands/timeline/track/index.ts b/apps/web/src/commands/timeline/track/index.ts new file mode 100644 index 00000000..9d4bd9c3 --- /dev/null +++ b/apps/web/src/commands/timeline/track/index.ts @@ -0,0 +1,4 @@ +export { AddTrackCommand } from "./add-track"; +export { RemoveTrackCommand } from "./remove-track"; +export { ToggleTrackMuteCommand } from "./toggle-track-mute"; +export { ToggleTrackVisibilityCommand } from "./toggle-track-visibility"; diff --git a/apps/web/src/commands/timeline/track/remove-track.ts b/apps/web/src/commands/timeline/track/remove-track.ts new file mode 100644 index 00000000..13a9ff4e --- /dev/null +++ b/apps/web/src/commands/timeline/track/remove-track.ts @@ -0,0 +1,30 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { SceneTracks } from "@/timeline"; + +export class RemoveTrackCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + const updatedTracks: SceneTracks = { + ...this.savedState, + overlay: this.savedState.overlay.filter((track) => track.id !== this.trackId), + audio: this.savedState.audio.filter((track) => track.id !== this.trackId), + }; + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/commands/timeline/track/toggle-track-mute.ts new file mode 100644 index 00000000..0d1f41cd --- /dev/null +++ b/apps/web/src/commands/timeline/track/toggle-track-mute.ts @@ -0,0 +1,41 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks } from "@/timeline"; +import { EditorCore } from "@/core"; +import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/timeline"; + +export class ToggleTrackMuteCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const targetTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + }); + if (!targetTrack) { + return; + } + + const updatedTracks = updateTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + update: (track) => + canTrackHaveAudio(track) ? { ...track, muted: !track.muted } : track, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/commands/timeline/track/toggle-track-visibility.ts new file mode 100644 index 00000000..f4160e3f --- /dev/null +++ b/apps/web/src/commands/timeline/track/toggle-track-visibility.ts @@ -0,0 +1,45 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks } from "@/timeline"; +import { EditorCore } from "@/core"; +import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/timeline"; + +export class ToggleTrackVisibilityCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const targetTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + }); + if (!targetTrack) { + return; + } + + const updatedTracks = updateTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + update: (track) => { + if (canTrackBeHidden(track)) { + return { ...track, hidden: !track.hidden }; + } + return track; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/tracks-snapshot.ts b/apps/web/src/commands/timeline/tracks-snapshot.ts new file mode 100644 index 00000000..5b6d871a --- /dev/null +++ b/apps/web/src/commands/timeline/tracks-snapshot.ts @@ -0,0 +1,21 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks } from "@/timeline"; +import { EditorCore } from "@/core"; + +export class TracksSnapshotCommand extends Command { + constructor( + private before: SceneTracks, + private after: SceneTracks, + ) { + super(); + } + + execute(): CommandResult | undefined { + EditorCore.getInstance().timeline.updateTracks(this.after); + return undefined; + } + + undo(): void { + EditorCore.getInstance().timeline.updateTracks(this.before); + } +} diff --git a/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx b/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx deleted file mode 100644 index 0086384e..00000000 --- a/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; - -export function DeleteProjectDialog({ - isOpen, - onOpenChange, - onConfirm, - projectNames, -}: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; - projectNames: string[]; -}) { - const count = projectNames.length; - const isSingle = count === 1; - const singleName = isSingle ? projectNames[0] : null; - - return ( - - { - event.preventDefault(); - event.stopPropagation(); - }} - > - - - {singleName ? ( - <> - {"Delete '"} - - {singleName} - - {"'?"} - - ) : ( - `Delete ${count} projects?` - )} - - - - - Warning - - This will permanently delete{" "} - {singleName ? `"${singleName}"` : `${count} projects`} and all - associated files. - - -
- - -
-
- - - - -
-
- ); -} diff --git a/apps/web/src/components/editor/dialogs/migration-dialog.tsx b/apps/web/src/components/editor/dialogs/migration-dialog.tsx deleted file mode 100644 index 4c9550c2..00000000 --- a/apps/web/src/components/editor/dialogs/migration-dialog.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { useEditor } from "@/hooks/use-editor"; -import { Loader2 } from "lucide-react"; - -export function MigrationDialog() { - const editor = useEditor(); - const migrationState = editor.project.getMigrationState(); - - if (!migrationState.isMigrating) return null; - - const title = migrationState.projectName - ? "Updating project" - : "Updating projects"; - const description = migrationState.projectName - ? `Upgrading "${migrationState.projectName}" from v${migrationState.fromVersion} to v${migrationState.toVersion}` - : `Upgrading projects from v${migrationState.fromVersion} to v${migrationState.toVersion}`; - - return ( - - event.preventDefault()} - onEscapeKeyDown={(event) => event.preventDefault()} - > - - {title} - {description} - - -
- -
-
-
- ); -} diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx deleted file mode 100644 index ed4eb990..00000000 --- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import type { TProjectMetadata } from "@/lib/project/types"; -import { formatDate } from "@/utils/date"; -import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm"; -import { Button } from "@/components/ui/button"; - -function InfoRow({ - label, - value, -}: { - label: string; - value: string | React.ReactNode; -}) { - return ( -
- {label} - {value} -
- ); -} - -export function ProjectInfoDialog({ - isOpen, - onOpenChange, - project, -}: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - project: TProjectMetadata; -}) { - const durationSeconds = mediaTimeToSeconds({ time: project.duration }); - const durationFormatted = - project.duration > 0 - ? (formatTimecode({ time: project.duration, format: durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "") - : "0:00"; - - return ( - - event.preventDefault()}> - - - {project.name} - - - - - - - - - {project.id.slice(0, 8)} - - } - /> - - - - - - - - ); -} diff --git a/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx b/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx deleted file mode 100644 index 73024d71..00000000 --- a/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { useState } from "react"; -import { Label } from "@/components/ui/label"; - -export function RenameProjectDialog({ - isOpen, - onOpenChange, - onConfirm, - projectName, -}: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: (newName: string) => void; - projectName: string; -}) { - const [name, setName] = useState(projectName); - - const handleOpenChange = (open: boolean) => { - if (open) { - setName(projectName); - } - onOpenChange(open); - }; - - return ( - - - - Rename project - - - - - setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - onConfirm(name); - } - }} - placeholder="Enter a new name" - /> - - - - - - - - - ); -} diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx deleted file mode 100644 index 70802b5b..00000000 --- a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx +++ /dev/null @@ -1,229 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { - type KeyboardShortcut, - useKeyboardShortcutsHelp, -} from "@/hooks/use-keyboard-shortcuts-help"; -import { useKeybindingsStore } from "@/stores/keybindings-store"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -export function ShortcutsDialog({ - isOpen, - onOpenChange, -}: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; -}) { - const [recordingShortcut, setRecordingShortcut] = - useState(null); - - const { - updateKeybinding, - removeKeybinding, - getKeybindingString, - validateKeybinding, - getKeybindingsForAction, - setIsRecording, - resetToDefaults, - isRecording, - } = useKeybindingsStore(); - - const { shortcuts } = useKeyboardShortcutsHelp(); - - const categories = Array.from(new Set(shortcuts.map((s) => s.category))); - - useEffect(() => { - if (!isRecording || !recordingShortcut) return; - - const handleKeyDown = (e: KeyboardEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const keyString = getKeybindingString(e); - if (keyString) { - const conflict = validateKeybinding( - keyString, - recordingShortcut.action, - ); - if (conflict) { - toast.error( - `Key "${keyString}" is already bound to "${conflict.existingAction}"`, - ); - setRecordingShortcut(null); - return; - } - - const oldKeys = getKeybindingsForAction(recordingShortcut.action); - for (const key of oldKeys) { - removeKeybinding(key); - } - - updateKeybinding(keyString, recordingShortcut.action); - - setIsRecording(false); - setRecordingShortcut(null); - } - }; - - const handleClickOutside = () => { - setRecordingShortcut(null); - setIsRecording(false); - }; - - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("click", handleClickOutside); - - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("click", handleClickOutside); - }; - }, [ - recordingShortcut, - getKeybindingString, - updateKeybinding, - removeKeybinding, - validateKeybinding, - getKeybindingsForAction, - setIsRecording, - isRecording, - ]); - - const handleStartRecording = (shortcut: KeyboardShortcut) => { - setRecordingShortcut(shortcut); - setIsRecording(true); - }; - - return ( - - - - Keyboard shortcuts - - - -
- {categories.map((category) => ( -
-

- {category} -

-
- {shortcuts - .filter((shortcut) => shortcut.category === category) - .map((shortcut) => ( - handleStartRecording(shortcut)} - /> - ))} -
-
- ))} -
-
- - - -
-
- ); -} - -function ShortcutItem({ - shortcut, - isRecording, - onStartRecording, -}: { - shortcut: KeyboardShortcut; - isRecording: boolean; - onStartRecording: (params: { shortcut: KeyboardShortcut }) => void; -}) { - const displayKeys = shortcut.keys.filter((key: string) => { - if ( - key.includes("Cmd") && - shortcut.keys.includes(key.replace("Cmd", "Ctrl")) - ) - return false; - - return true; - }); - - return ( -
-
- {shortcut.icon && ( -
{shortcut.icon}
- )} - {shortcut.description} -
-
- {displayKeys.map((key: string, index: number) => ( -
-
- {key.split("+").map((keyPart: string, partIndex: number) => { - const keyId = `${shortcut.id}-${index}-${partIndex}`; - return ( - onStartRecording({ shortcut })} - > - {keyPart} - - ); - })} -
- {index < displayKeys.length - 1 && ( - or - )} -
- ))} -
-
- ); -} - -function EditableShortcutKey({ - children, - isRecording, - onStartRecording, -}: { - children: React.ReactNode; - isRecording: boolean; - onStartRecording: () => void; -}) { - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onStartRecording(); - }; - - return ( - - ); -} diff --git a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx deleted file mode 100644 index 0e854a93..00000000 --- a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"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 ( - !open && onDismiss()}> - - - Don't lose your projects - - -

- Your browser can automatically delete your projects when storage - runs low. -

-

- Allow OpenCut to protect them? -

-
- - - - -
-
- ); -} diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index 44e8af68..6404dc01 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -10,20 +10,20 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; import Link from "next/link"; -import { RenameProjectDialog } from "./dialogs/rename-project-dialog"; -import { DeleteProjectDialog } from "./dialogs/delete-project-dialog"; +import { RenameProjectDialog } from "@/project/components/rename-project-dialog"; +import { DeleteProjectDialog } from "@/project/components/delete-project-dialog"; import { useRouter } from "next/navigation"; import { FaDiscord } from "react-icons/fa6"; import { ExportButton } from "./export-button"; -import { FeedbackPopover } from "@/lib/feedback/components/feedback-popover"; +import { FeedbackPopover } from "@/feedback/components/feedback-popover"; import { ThemeToggle } from "../theme-toggle"; -import { DEFAULT_LOGO_URL } from "@/lib/site/brand"; -import { SOCIAL_LINKS } from "@/lib/site/social"; +import { DEFAULT_LOGO_URL } from "@/site/brand"; +import { SOCIAL_LINKS } from "@/site/social"; import { toast } from "sonner"; -import { useEditor } from "@/hooks/use-editor"; +import { useEditor } from "@/editor/use-editor"; import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; +import { ShortcutsDialog } from "@/actions/components/shortcuts-dialog"; import Image from "next/image"; import { cn } from "@/utils/ui"; diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index e66067da..e738fb68 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -18,22 +18,22 @@ import { getExportMimeType, getExportFileExtension, downloadBuffer, -} from "@/lib/export"; +} from "@/export"; import { Check, Copy, Download, RotateCcw } from "lucide-react"; import { EXPORT_FORMAT_VALUES, EXPORT_QUALITY_VALUES, type ExportFormat, type ExportQuality, -} from "@/lib/export"; +} from "@/export"; import { Section, SectionContent, SectionHeader, SectionTitle, } from "@/components/section"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_EXPORT_OPTIONS } from "@/lib/export/defaults"; +import { useEditor } from "@/editor/use-editor"; +import { DEFAULT_EXPORT_OPTIONS } from "@/export/defaults"; function isExportFormat(value: string): value is ExportFormat { return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); diff --git a/apps/web/src/components/editor/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx index 54196c76..bc5533ce 100644 --- a/apps/web/src/components/editor/onboarding.tsx +++ b/apps/web/src/components/editor/onboarding.tsx @@ -3,8 +3,8 @@ import { ArrowRightIcon } from "lucide-react"; import { useState } from "react"; import ReactMarkdown from "react-markdown"; -import { SOCIAL_LINKS } from "@/lib/site/social"; -import { useLocalStorage } from "@/hooks/storage/use-local-storage"; +import { SOCIAL_LINKS } from "@/site/social"; +import { useLocalStorage } from "@/services/storage/use-local-storage"; import { Button } from "../ui/button"; import { Dialog, DialogBody, DialogContent, DialogTitle } from "../ui/dialog"; diff --git a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx new file mode 100644 index 00000000..1539d305 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx @@ -0,0 +1,124 @@ +import type { ElementType } from "react"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { + ArrowRightDoubleIcon, + ClosedCaptionIcon, + Folder03Icon, + Happy01Icon, + HeadphonesIcon, + MagicWand05Icon, + TextIcon, + Settings01Icon, + SlidersHorizontalIcon, + ColorsIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; + +export const TAB_KEYS = [ + "media", + "sounds", + "text", + "stickers", + "effects", + "transitions", + "captions", + "adjustment", + "settings", +] as const; + +export type Tab = (typeof TAB_KEYS)[number]; + +const createHugeiconsIcon = + ({ icon }: { icon: IconSvgElement }) => + ({ className }: { className?: string }) => ( + + ); + +export const tabs = { + media: { + icon: createHugeiconsIcon({ icon: Folder03Icon }), + label: "Media", + }, + sounds: { + icon: createHugeiconsIcon({ icon: HeadphonesIcon }), + label: "Sounds", + }, + text: { + icon: createHugeiconsIcon({ icon: TextIcon }), + label: "Text", + }, + stickers: { + icon: createHugeiconsIcon({ icon: Happy01Icon }), + label: "Stickers", + }, + effects: { + icon: createHugeiconsIcon({ icon: MagicWand05Icon }), + label: "Effects", + }, + transitions: { + icon: createHugeiconsIcon({ icon: ArrowRightDoubleIcon }), + label: "Transitions", + }, + captions: { + icon: createHugeiconsIcon({ icon: ClosedCaptionIcon }), + label: "Captions", + }, + adjustment: { + icon: createHugeiconsIcon({ icon: SlidersHorizontalIcon }), + label: "Adjustment", + }, + settings: { + icon: createHugeiconsIcon({ icon: Settings01Icon }), + label: "Settings", + }, +} satisfies Record< + Tab, + { icon: ElementType<{ className?: string }>; label: string } +>; + +export type MediaViewMode = "grid" | "list"; +export type MediaSortKey = "name" | "type" | "duration" | "size"; +export type MediaSortOrder = "asc" | "desc"; + +interface AssetsPanelStore { + activeTab: Tab; + setActiveTab: (tab: Tab) => void; + highlightMediaId: string | null; + requestRevealMedia: (mediaId: string) => void; + clearHighlight: () => void; + + /* Media */ + mediaViewMode: MediaViewMode; + setMediaViewMode: (mode: MediaViewMode) => void; + mediaSortBy: MediaSortKey; + mediaSortOrder: MediaSortOrder; + setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void; +} + +export const useAssetsPanelStore = create()( + persist( + (set) => ({ + activeTab: "media", + setActiveTab: (tab) => set({ activeTab: tab }), + highlightMediaId: null, + requestRevealMedia: (mediaId) => + set({ activeTab: "media", highlightMediaId: mediaId }), + clearHighlight: () => set({ highlightMediaId: null }), + mediaViewMode: "grid", + setMediaViewMode: (mode) => set({ mediaViewMode: mode }), + mediaSortBy: "name", + mediaSortOrder: "asc", + setMediaSort: (key, order) => + set({ mediaSortBy: key, mediaSortOrder: order }), + }), + { + name: "assets-panel", + partialize: (state) => ({ + mediaViewMode: state.mediaViewMode, + mediaSortBy: state.mediaSortBy, + mediaSortOrder: state.mediaSortOrder, + }), + }, + ), +); 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 166c81db..2f8bc64a 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -10,9 +10,9 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useEditor } from "@/hooks/use-editor"; -import { clearDragData, setDragData } from "@/lib/drag-data"; -import type { TimelineDragData } from "@/lib/timeline/drag"; +import { useEditor } from "@/editor/use-editor"; +import { clearDragData, setDragData } from "@/timeline/drag-data"; +import type { TimelineDragData } from "@/timeline/drag"; import { cn } from "@/utils/ui"; export interface DraggableItemProps { diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx index 1cdbf295..bae73299 100644 --- a/apps/web/src/components/editor/panels/assets/index.tsx +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -1,15 +1,15 @@ "use client"; import { Separator } from "@/components/ui/separator"; -import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store"; +import { type Tab, useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store"; import { TabBar } from "./tabbar"; -import { Captions } from "./views/captions"; +import { Captions } from "@/subtitles/components/assets-view"; import { MediaView } from "./views/assets"; import { SettingsView } from "./views/settings"; -import { SoundsView } from "./views/sounds"; -import { StickersView } from "./views/stickers"; -import { TextView } from "./views/text"; -import { EffectsView } from "./views/effects"; +import { SoundsView } from "@/sounds/components/assets-view"; +import { StickersView } from "@/stickers/components/assets-view"; +import { TextView } from "@/text/components/assets-view"; +import { EffectsView } from "@/effects/components/assets-view"; export function AssetsPanel() { const { activeTab } = useAssetsPanelStore(); diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index da53bed0..3b084c0d 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -12,7 +12,7 @@ import { TAB_KEYS, tabs, useAssetsPanelStore, -} from "@/stores/assets-panel-store"; +} from "@/components/editor/panels/assets/assets-panel-store"; export function TabBar() { const { activeTab, setActiveTab } = useAssetsPanelStore(); 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 33b47c67..4afd22b7 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -25,28 +25,28 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { useEditor } from "@/hooks/use-editor"; -import { useFileUpload } from "@/hooks/use-file-upload"; -import { invokeAction } from "@/lib/actions"; -import { processMediaAssets } from "@/lib/media/processing"; -import { showMediaUploadToast } from "@/lib/media/upload-toast"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEditor } from "@/editor/use-editor"; +import { useFileUpload } from "@/media/use-file-upload"; +import { invokeAction } from "@/actions"; +import { processMediaAssets } from "@/media/processing"; +import { showMediaUploadToast } from "@/media/upload-toast"; import { SelectableItem, SelectableSurface, useSelection, useSelectionScope, -} from "@/lib/selection"; -import { buildElementFromMedia } from "@/lib/timeline/element-utils"; +} from "@/selection"; +import { buildElementFromMedia } from "@/timeline/element-utils"; import { type MediaSortKey, type MediaSortOrder, type MediaViewMode, useAssetsPanelStore, -} from "@/stores/assets-panel-store"; -import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; +} from "@/components/editor/panels/assets/assets-panel-store"; +import { MASKABLE_ELEMENT_TYPES } from "@/timeline"; +import type { MediaAsset } from "@/media/types"; import { cn } from "@/utils/ui"; import { CloudUploadIcon, diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx deleted file mode 100644 index 040df749..00000000 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { useReducer, useRef, useState } from "react"; -import { extractTimelineAudio } from "@/lib/media/mediabunny"; -import { useEditor } from "@/hooks/use-editor"; -import { TRANSCRIPTION_DIAGNOSTICS_SCOPE } from "@/lib/transcription/diagnostics"; -import { DEFAULT_TRANSCRIPTION_SAMPLE_RATE } from "@/lib/transcription/audio"; -import { TRANSCRIPTION_LANGUAGES } from "@/lib/transcription/supported-languages"; -import type { - CaptionChunk, - TranscriptionLanguage, - TranscriptionProgress, -} from "@/lib/transcription/types"; -import { transcriptionService } from "@/services/transcription/service"; -import { decodeAudioToFloat32 } from "@/lib/media/audio"; -import { buildCaptionChunks } from "@/lib/transcription/caption"; -import { insertCaptionChunksAsTextTrack } from "@/lib/subtitles/insert"; -import { parseSubtitleFile } from "@/lib/subtitles/parse"; -import { Spinner } from "@/components/ui/spinner"; -import { - Section, - SectionContent, - SectionField, - SectionFields, -} from "@/components/section"; -import { AlertCircleIcon, CloudUploadIcon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import type { DiagnosticSeverity } from "@/lib/diagnostics/types"; - -const DIAGNOSTIC_BUTTON_VARIANT: Record< - DiagnosticSeverity, - "caution" | "destructive-foreground" -> = { - caution: "caution", - error: "destructive-foreground", -}; - -type ProcessingState = - | { status: "idle"; error: string | null; warnings: string[] } - | { status: "processing"; step: string }; - -type ProcessingAction = - | { type: "start"; step: string } - | { type: "update_step"; step: string } - | { type: "succeed"; warnings: string[] } - | { type: "fail"; error: string }; - -const IDLE_STATE: ProcessingState = { - status: "idle", - error: null, - warnings: [], -}; - -function processingReducer( - state: ProcessingState, - action: ProcessingAction, -): ProcessingState { - switch (action.type) { - case "start": - return { status: "processing", step: action.step }; - case "update_step": - if (state.status !== "processing") return state; - return { status: "processing", step: action.step }; - case "succeed": - return { status: "idle", error: null, warnings: action.warnings }; - case "fail": - return { status: "idle", error: action.error, warnings: [] }; - } -} - -export function Captions() { - const [selectedLanguage, setSelectedLanguage] = - useState("auto"); - const [processing, dispatch] = useReducer(processingReducer, IDLE_STATE); - const containerRef = useRef(null); - const fileInputRef = useRef(null); - const editor = useEditor(); - - const isProcessing = processing.status === "processing"; - - const activeDiagnostics = useEditor((e) => - e.diagnostics.getActive({ scope: TRANSCRIPTION_DIAGNOSTICS_SCOPE }), - ); - - const handleProgress = (progress: TranscriptionProgress) => { - if (progress.status === "loading-model") { - dispatch({ - type: "update_step", - step: `Loading model ${Math.round(progress.progress)}%`, - }); - } else if (progress.status === "transcribing") { - dispatch({ type: "update_step", step: "Transcribing..." }); - } - }; - - const insertCaptions = ({ - captions, - }: { - captions: CaptionChunk[]; - }): boolean => { - const trackId = insertCaptionChunksAsTextTrack({ editor, captions }); - return trackId !== null; - }; - - const handleGenerateTranscript = async () => { - dispatch({ type: "start", step: "Extracting audio..." }); - try { - const audioBlob = await extractTimelineAudio({ - tracks: editor.scenes.getActiveScene().tracks, - mediaAssets: editor.media.getAssets(), - totalDuration: editor.timeline.getTotalDuration(), - }); - - dispatch({ type: "update_step", step: "Preparing audio..." }); - const { samples } = await decodeAudioToFloat32({ - audioBlob, - sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE, - }); - - const result = await transcriptionService.transcribe({ - audioData: samples, - language: selectedLanguage === "auto" ? undefined : selectedLanguage, - onProgress: handleProgress, - }); - - dispatch({ type: "update_step", step: "Generating captions..." }); - const captionChunks = buildCaptionChunks({ segments: result.segments }); - - if (!insertCaptions({ captions: captionChunks })) { - dispatch({ type: "fail", error: "No captions were generated" }); - return; - } - - dispatch({ type: "succeed", warnings: [] }); - } catch (error) { - console.error("Transcription failed:", error); - dispatch({ - type: "fail", - error: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }); - } - }; - - const handleImportClick = () => { - fileInputRef.current?.click(); - }; - - const handleImportFile = async ({ file }: { file: File }) => { - dispatch({ type: "start", step: "Reading subtitle file..." }); - try { - const input = await file.text(); - const result = parseSubtitleFile({ - fileName: file.name, - input, - }); - - if (result.captions.length === 0) { - dispatch({ - type: "fail", - error: "No valid subtitle cues were found in the subtitle file", - }); - return; - } - - dispatch({ type: "update_step", step: "Importing subtitles..." }); - - if (!insertCaptions({ captions: result.captions })) { - dispatch({ type: "fail", error: "No captions were generated" }); - return; - } - - const nextWarnings = [...result.warnings]; - if (result.skippedCueCount > 0) { - nextWarnings.unshift( - `Imported ${result.captions.length} subtitle cue(s) and skipped ${result.skippedCueCount} malformed cue(s).`, - ); - } - - dispatch({ type: "succeed", warnings: nextWarnings }); - } catch (error) { - console.error("Subtitle import failed:", error); - dispatch({ - type: "fail", - error: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }); - } - }; - - const handleFileChange = async ({ - event, - }: { - event: React.ChangeEvent; - }) => { - const file = event.target.files?.[0]; - if (event.target) { - event.target.value = ""; - } - if (!file) return; - - await handleImportFile({ file }); - }; - - const handleLanguageChange = ({ value }: { value: string }) => { - if (value === "auto") { - setSelectedLanguage("auto"); - return; - } - - const matchedLanguage = TRANSCRIPTION_LANGUAGES.find( - (language) => language.code === value, - ); - if (!matchedLanguage) return; - setSelectedLanguage(matchedLanguage.code); - }; - - const error = processing.status === "idle" ? processing.error : null; - const warnings = processing.status === "idle" ? processing.warnings : []; - - return ( - -
- {!isProcessing && - activeDiagnostics.map((diagnostic) => ( - - - - - {diagnostic.message} - - ))} - -
- - } - ref={containerRef} - > - void handleFileChange({ event })} - /> -
- - - - - - - - - {error && ( -
-

{error}

-
- )} - {warnings.length > 0 && ( -
-
    - {warnings.map((warning) => ( -
  • {warning}
  • - ))} -
-
- )} -
-
-
- ); -} diff --git a/apps/web/src/components/editor/panels/assets/views/effects.tsx b/apps/web/src/components/editor/panels/assets/views/effects.tsx deleted file mode 100644 index 74128df6..00000000 --- a/apps/web/src/components/editor/panels/assets/views/effects.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { useEffect, useRef, useCallback } from "react"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -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 "@/lib/effects/types"; - -export function EffectsView() { - const effects = effectsRegistry.getAll(); - - return ( - - - - ); -} - -function EffectsGrid({ effects }: { effects: EffectDefinition[] }) { - return ( -
- {effects.map((effect) => ( - - ))} -
- ); -} - -function EffectPreviewCanvas({ effectType }: { effectType: string }) { - const canvasRef = useRef(null); - - useEffect(() => { - const render = () => { - if (canvasRef.current) { - effectPreviewService.renderPreview({ - effectType, - params: {}, - targetCanvas: canvasRef.current, - }); - } - }; - - render(); - return effectPreviewService.onPreviewImageReady({ callback: render }); - }, [effectType]); - - return ; -} - -function EffectItem({ effect }: { effect: EffectDefinition }) { - const editor = useEditor(); - - const handleAddToTimeline = useCallback(() => { - const currentTime = editor.playback.getCurrentTime(); - const element = buildEffectElement({ - effectType: effect.type, - startTime: currentTime, - }); - - editor.timeline.insertElement({ - placement: { mode: "auto", trackType: "effect" }, - element, - }); - }, [editor, effect.type]); - - const preview = ; - - return ( - - ); -} 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 index 099e335a..9ce34ebb 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx @@ -12,12 +12,12 @@ import { Popover, PopoverTrigger } from "@/components/ui/popover"; import { BACKGROUND_BLUR_INTENSITY_PRESETS, DEFAULT_BACKGROUND_BLUR_INTENSITY, -} from "@/lib/background/blur"; -import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/color"; +} from "@/background/blur"; +import { DEFAULT_BACKGROUND_COLOR } from "@/background/color"; 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 { useEditor } from "@/editor/use-editor"; import { effectPreviewService } from "@/services/renderer/effect-preview"; import { cn } from "@/utils/ui"; 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 index 4d87493a..bf3a1c16 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -9,9 +9,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { FPS_PRESETS } from "@/lib/fps/presets"; -import { floatToFrameRate, frameRateToFloat } from "@/lib/fps/utils"; -import { useEditor } from "@/hooks/use-editor"; +import { FPS_PRESETS } from "@/fps/presets"; +import { floatToFrameRate, frameRateToFloat } from "@/fps/utils"; +import { useEditor } from "@/editor/use-editor"; import { Section, SectionContent, @@ -22,7 +22,7 @@ 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 { useEditorStore } from "@/editor/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"; @@ -30,7 +30,7 @@ import { cn } from "@/utils/ui"; import { dimensionToAspectRatio } from "@/utils/geometry"; import { formatNumberForDisplay } from "@/utils/math"; import { OcSquarePlusIcon } from "@/components/icons"; -import type { TCanvasSize } from "@/lib/project/types"; +import type { TCanvasSize } from "@/project/types"; type SettingsView = "project-info" | "background"; diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx deleted file mode 100644 index c7a1029c..00000000 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ /dev/null @@ -1,563 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -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 "@/lib/sounds/types"; -import { cn } from "@/utils/ui"; -import { - FavouriteIcon, - FilterMailIcon, - PauseIcon, - PlayIcon, - PlusSignIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function SoundsView() { - return ( -
- -
- - Sound effects - Saved - -
- - - - - - - -
-
- ); -} - -function SoundEffectsView() { - const { - topSoundEffects, - isLoading, - searchQuery, - setSearchQuery, - scrollPosition, - setScrollPosition, - loadSavedSounds, - showCommercialOnly, - toggleCommercialFilter, - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - } = useSoundsStore(); - const { - results: searchResults, - isLoading: isSearching, - loadMore, - hasNextPage, - isLoadingMore, - } = useSoundSearch({ - query: searchQuery, - commercialOnly: showCommercialOnly, - }); - - const [playingId, setPlayingId] = useState(null); - const [audioElement, setAudioElement] = useState( - null, - ); - - const { scrollAreaRef, handleScroll } = useInfiniteScroll({ - onLoadMore: loadMore, - hasMore: hasNextPage, - isLoading: isLoadingMore || isSearching, - }); - - useEffect(() => { - loadSavedSounds(); - }, [loadSavedSounds]); - - useEffect(() => { - if (hasLoaded) { - return; - } - - let shouldIgnore = false; - - const fetchTopSounds = async () => { - try { - if (!shouldIgnore) { - setLoading({ loading: true }); - setError({ error: null }); - } - - const response = await fetch( - "/api/sounds/search?page_size=50&sort=downloads", - ); - - if (!shouldIgnore) { - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - - const data = await response.json(); - setTopSoundEffects({ sounds: data.results }); - setHasLoaded({ loaded: true }); - - setCurrentPage({ page: 1 }); - setHasNextPage({ hasNext: !!data.next }); - setTotalCount({ count: data.count }); - } - } catch (error) { - if (!shouldIgnore) { - console.error("Failed to fetch top sounds:", error); - setError({ - error: - error instanceof Error ? error.message : "Failed to load sounds", - }); - } - } finally { - if (!shouldIgnore) { - setLoading({ loading: false }); - } - } - }; - - const timeoutId = setTimeout(fetchTopSounds, 100, {}); - - return () => { - shouldIgnore = true; - clearTimeout(timeoutId); - }; - }, [ - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - ]); - - useEffect(() => { - if (!scrollAreaRef.current || scrollPosition <= 0) { - return; - } - - const restoreScrollPosition = () => { - scrollAreaRef.current?.scrollTo({ top: scrollPosition }); - }; - - const timeoutId = setTimeout(restoreScrollPosition, 100, {}); - - return () => clearTimeout(timeoutId); - }, [scrollPosition, scrollAreaRef]); - - const handleScrollWithPosition = ({ - currentTarget, - }: React.UIEvent) => { - const { scrollTop } = currentTarget; - setScrollPosition({ position: scrollTop }); - handleScroll({ currentTarget } as React.UIEvent); - }; - - const displayedSounds = searchQuery ? searchResults : topSoundEffects; - - const playSound = ({ sound }: { sound: SoundEffect }) => { - if (playingId === sound.id) { - audioElement?.pause(); - setPlayingId(null); - return; - } - - audioElement?.pause(); - - if (sound.previewUrl) { - const audio = new Audio(sound.previewUrl); - audio.addEventListener("ended", () => { - setPlayingId(null); - }); - audio.addEventListener("error", () => { - setPlayingId(null); - }); - audio.play().catch((error) => { - console.error("Failed to play sound preview:", error); - setPlayingId(null); - }); - - setAudioElement(audio); - setPlayingId(sound.id); - } - }; - - return ( -
-
- - setSearchQuery({ query: currentTarget.value }) - } - showClearIcon - onClear={() => setSearchQuery({ query: "" })} - /> - - - - - - toggleCommercialFilter()} - > - Show only commercially licensed - -
- {showCommercialOnly - ? "Only showing sounds licensed for commercial use" - : "Showing all sounds regardless of license"} -
-
-
-
- -
- -
- {isLoading && !searchQuery && ( -
- Loading sounds... -
- )} - {isSearching && searchQuery && ( -
Searching...
- )} - {displayedSounds.map((sound) => ( - - ))} - {!isLoading && !isSearching && displayedSounds.length === 0 && ( -
- {searchQuery ? "No sounds found" : "No sounds available"} -
- )} - {isLoadingMore && ( -
- Loading more sounds... -
- )} -
-
-
-
- ); -} - -function SavedSoundsView() { - const { - savedSounds, - isLoadingSavedSounds, - savedSoundsError, - loadSavedSounds, - clearSavedSounds, - } = useSoundsStore(); - - const [playingId, setPlayingId] = useState(null); - const [audioElement, setAudioElement] = useState( - null, - ); - - const [showClearDialog, setShowClearDialog] = useState(false); - - useEffect(() => { - loadSavedSounds(); - }, [loadSavedSounds]); - - const playSound = ({ sound }: { sound: SoundEffect }) => { - if (playingId === sound.id) { - audioElement?.pause(); - setPlayingId(null); - return; - } - - audioElement?.pause(); - - if (sound.previewUrl) { - const audio = new Audio(sound.previewUrl); - audio.addEventListener("ended", () => { - setPlayingId(null); - }); - audio.addEventListener("error", () => { - setPlayingId(null); - }); - audio.play().catch((error) => { - console.error("Failed to play sound preview:", error); - setPlayingId(null); - }); - - setAudioElement(audio); - setPlayingId(sound.id); - } - }; - - const convertToSoundEffect = ({ - savedSound, - }: { - savedSound: SavedSound; - }): SoundEffect => ({ - id: savedSound.id, - name: savedSound.name, - description: "", - url: "", - previewUrl: savedSound.previewUrl, - downloadUrl: savedSound.downloadUrl, - duration: savedSound.duration, - filesize: 0, - type: "audio", - channels: 0, - bitrate: 0, - bitdepth: 0, - samplerate: 0, - username: savedSound.username, - tags: savedSound.tags, - license: savedSound.license, - created: savedSound.savedAt, - downloads: 0, - rating: 0, - ratingCount: 0, - }); - - if (isLoadingSavedSounds) { - return ( -
-
- Loading saved sounds... -
-
- ); - } - - if (savedSoundsError) { - return ( -
-
- Error: {savedSoundsError} -
-
- ); - } - - if (savedSounds.length === 0) { - return ( -
- -
-

No saved sounds

-

- Click the heart icon on any sound to save it here -

-
-
- ); - } - - return ( -
-
-

- {savedSounds.length} saved{" "} - {savedSounds.length === 1 ? "sound" : "sounds"} -

- - - - - - - Clear all saved sounds? - - This will permanently remove all {savedSounds.length} saved - sounds from your collection. This action cannot be undone. - - - - - - - - -
- -
- -
- {savedSounds.map((sound) => ( - - ))} -
-
-
-
- ); -} - -interface AudioItemProps { - sound: SoundEffect; - isPlaying: boolean; - onPlay: ({ sound }: { sound: SoundEffect }) => void; -} - -function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) { - const { addSoundToTimeline, isSoundSaved, toggleSavedSound } = - useSoundsStore(); - const isSaved = isSoundSaved({ soundId: sound.id }); - - const handleClick = () => { - onPlay({ sound }); - }; - - const handleSaveClick = ({ - stopPropagation, - }: React.MouseEvent) => { - stopPropagation(); - toggleSavedSound({ soundEffect: sound }); - }; - - const handleAddToTimeline = async ({ - stopPropagation, - }: React.MouseEvent) => { - stopPropagation(); - await addSoundToTimeline({ sound }); - }; - - return ( -
- - -
- - -
-
- ); -} diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx deleted file mode 100644 index 318aaccb..00000000 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ /dev/null @@ -1,455 +0,0 @@ -"use client"; - -import Image from "next/image"; -import type { CSSProperties } from "react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useEditor } from "@/hooks/use-editor"; -import { resolveStickerIntrinsicSize } from "@/lib/stickers"; -import { - buildGraphicElement, - buildStickerElement, -} from "@/lib/timeline/element-utils"; -import { STICKER_CATEGORIES } from "@/lib/stickers/categories"; -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, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function StickersView() { - const { - browseContent, - browseStickers, - searchQuery, - searchStickers, - selectedCategory, - setSearchQuery, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - useEffect(() => { - if (viewMode === "browse" && !browseContent) { - void browseStickers(); - } - }, [browseContent, browseStickers, viewMode]); - - return ( -
-
- { - setSearchQuery({ query: e.target.value }); - void searchStickers({ query: e.target.value }); - }} - showClearIcon - onClear={() => { - setSearchQuery({ query: "" }); - void searchStickers({ query: "" }); - }} - className="w-full" - containerClassName="w-full" - /> -
- - { - setSelectedCategory({ category: value as StickerCategory }); - }} - variant="underline" - className="mt-2 flex min-h-0 flex-1 flex-col" - > - - {Object.entries(STICKER_CATEGORIES).map(([key, label]) => ( - - {label} - - ))} - -
- -
-
-
- ); -} - -function StickerGrid({ - items, - shouldCapSize = false, -}: { - items: StickerData[]; - shouldCapSize?: boolean; -}) { - const gridStyle: CSSProperties & { - "--sticker-min": string; - "--sticker-max"?: string; - } = { - gridTemplateColumns: shouldCapSize - ? "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 ( -
- {items.map((item) => ( - - ))} -
- ); -} - -function StickerRow({ items }: { items: StickerData[] }) { - return ( -
- {items.map((item) => ( -
- -
- ))} -
- ); -} - -function EmptyView({ message }: { message: string }) { - return ( -
- -
-

No stickers found

-

{message}

-
-
- ); -} - -function RegionBanner({ region }: { region: string }) { - return ( -
- - {region} -
- ); -} - -function StickersContentView() { - const { - browseContent, - clearRecentStickers, - isBrowsing, - isSearching, - searchQuery, - searchResults, - selectedCategory, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - if (viewMode === "search") { - if (isSearching) { - return ( -
- -
- ); - } - - if (searchResults?.items.length) { - const normalizedQuery = searchQuery.trim().toLowerCase(); - const isRegionSearch = - selectedCategory === "flags" && - resolveQueryToRegions({ query: normalizedQuery }) !== null; - const regionLabel = getRegionLabel({ query: normalizedQuery }); - - return ( -
- {isRegionSearch && } -
- - {searchResults.total} results - -
- -
- ); - } - - // "all" tab search — sections are in browseContent, fall through to section rendering below - if (selectedCategory !== "all" && searchQuery) { - return ; - } - } - - if (isBrowsing && !browseContent) { - return ( -
- -
- ); - } - - if (!browseContent?.sections.length) { - const categoryLabel = STICKER_CATEGORIES[selectedCategory]; - return ( - - ); - } - - return ( -
- {browseContent.sections.map((section) => ( - { - setSelectedCategory({ category }); - }} - /> - ))} -
- ); -} - -function StickerSection({ - section, - onClearRecent, - onSeeAll, -}: { - section: StickerBrowseSection; - onClearRecent: () => void; - onSeeAll: (category: StickerCategory) => void; -}) { - const hasHeader = - Boolean(section.title) || section.id === "recent" || section.action; - - return ( -
- {hasHeader && ( -
- {section.title ? ( -

{section.title}

- ) : ( -
- )} - -
- {section.id === "recent" && ( - - )} - - {section.action?.type === "see-all" && section.action.category && ( - - )} -
-
- )} - - {section.layout === "row" ? ( - - ) : ( - - )} -
- ); -} - -interface StickerItemProps { - item: StickerData; - shouldCapSize?: boolean; - containerClassName?: string; -} - -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 { - const currentTime = editor.playback.getCurrentTime(); - - let element: - | ReturnType - | ReturnType; - 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 ? ( - - {displayName} - - ) : ( - {displayName} setHasImageError(true)} - loading="lazy" - unoptimized - /> - )} -
- ); - - 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 ( -
- - {isAdding && ( -
- -
- )} -
- ); -} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx deleted file mode 100644 index da295da3..00000000 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { buildTextElement } from "@/lib/timeline/element-utils"; - -export function TextView() { - const editor = useEditor(); - - const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => { - const activeScene = editor.scenes.getActiveScene(); - if (!activeScene) return; - - const element = buildTextElement({ - raw: DEFAULTS.text.element, - startTime: currentTime, - }); - - editor.timeline.insertElement({ - element, - placement: { mode: "auto" }, - }); - }; - - return ( - - - Default text -
- } - dragData={{ - id: "temp-text-id", - type: DEFAULTS.text.element.type, - name: DEFAULTS.text.element.name, - content: DEFAULTS.text.element.content, - }} - aspectRatio={1} - onAddToTimeline={handleAddToTimeline} - shouldShowLabel={false} - /> - - ); -} diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx deleted file mode 100644 index 3d96f762..00000000 --- a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { findCurrentScene } from "@/lib/scenes"; -import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks"; - -export function BookmarkNoteOverlay() { - const editor = useEditor(); - const currentTime = editor.playback.getCurrentTime(); - const activeProject = editor.project.getActive(); - - if (!activeProject) { - return null; - } - - const activeScene = findCurrentScene({ - scenes: activeProject.scenes, - currentSceneId: activeProject.currentSceneId, - }); - - if (!activeScene) { - return null; - } - - const bookmarks = activeScene.bookmarks; - const activeBookmarks = getBookmarksActiveAtTime({ - bookmarks, - time: currentTime, - }); - const bookmarksWithNotes = activeBookmarks.filter( - (bookmark) => bookmark.note != null && bookmark.note.trim() !== "", - ); - - if (bookmarksWithNotes.length === 0) { - return null; - } - - return ( -
- {bookmarksWithNotes.map((bookmark) => ( -
- {bookmark.note} -
- ))} -
- ); -} diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx deleted file mode 100644 index 677f35db..00000000 --- a/apps/web/src/components/editor/panels/preview/context-menu.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -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, - containerRef, -}: { - onToggleFullscreen: () => void; - containerRef: React.RefObject; -}) { - 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 ( - - - Fit to screen - - - - Full screen - - - Save snapshot - - - Copy snapshot - - - - setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked }) - } - > - Show bookmark notes - - - ); -} diff --git a/apps/web/src/components/editor/panels/preview/cursors.ts b/apps/web/src/components/editor/panels/preview/cursors.ts deleted file mode 100644 index f04dbef0..00000000 --- a/apps/web/src/components/editor/panels/preview/cursors.ts +++ /dev/null @@ -1,21 +0,0 @@ -function svgCursor({ - svg, - hotspotX, - hotspotY, -}: { - svg: string; - hotspotX: number; - hotspotY: number; -}): string { - return `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${hotspotX} ${hotspotY}, crosshair`; -} - -/** Hotspot is at the nib tip, which is where anchor points land. */ -export const PEN_CURSOR = svgCursor({ - svg: ` - - -`, - hotspotX: 1, - hotspotY: 1, -}); diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx deleted file mode 100644 index 4270fcb3..00000000 --- a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"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 ( -
- {guide.renderOverlay({ - width: viewport.sceneWidth, - height: viewport.sceneHeight, - })} -
- ); -} diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx deleted file mode 100644 index bcac8075..00000000 --- a/apps/web/src/components/editor/panels/preview/guide-popover.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"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 ( - - {children} - -
- -
- {GUIDE_REGISTRY.map((guide) => ( - toggleGuide(guide.id)} - /> - ))} -
-
- - {options && ( - - -
{options}
-
- )} -
-
-
- ); -} - -function GridItem({ - label, - preview, - isSelected, - onClick, -}: { - label: string; - preview: React.ReactNode; - isSelected?: boolean; - onClick: () => void; -}) { - return ( - - ); -} diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx deleted file mode 100644 index 33baf9fc..00000000 --- a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx +++ /dev/null @@ -1,427 +0,0 @@ -"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 "ew-resize"; - if (normalized < 67.5) return "nwse-resize"; - if (normalized < 112.5) return "ns-resize"; - return "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 ( - - ); -} - -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 ( - -
- - ); -} - -export function CircleHandle({ - cursor, - screen, - size = HANDLE_SIZE, - isSelected = false, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - cursor?: string; - screen: { x: number; y: number }; - size?: number; - isSelected?: boolean; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - -
- - ); -} - -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 ( - -
- - ); -} - -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 ( - - - - ); -} - -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 ( - - ); -} - -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 ( - - ); -} - -export function CanvasPathOutline({ - pathData, - translateX = 0, - translateY = 0, - scaleX = 1, - scaleY = 1, - cursor, - strokeWidth = 1, - strokeOpacity = 0.75, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - pathData: string; - translateX?: number; - translateY?: number; - scaleX?: number; - scaleY?: number; - cursor?: string; - strokeWidth?: number; - strokeOpacity?: number; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - return ( - - ); -} - -export function LineOverlay({ - start, - end, - cursor, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - start: { x: number; y: number }; - end: { x: number; y: number }; - cursor?: string; - 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 && ( -
- )} -
- - ); -} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx deleted file mode 100644 index 369d854c..00000000 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ /dev/null @@ -1,294 +0,0 @@ -"use client"; - -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"; -import { useContainerSize } from "@/hooks/use-container-size"; -import { useFullscreen } from "@/hooks/use-fullscreen"; -import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import type { RootNode } from "@/services/renderer/nodes/root-node"; -import { buildScene } from "@/services/renderer/scene-builder"; -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 canvasSize = useEditor( - (e) => e.project.getActive()?.settings.canvasSize, - ); - - return { - 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(null); - const { toggleFullscreen } = useFullscreen({ containerRef }); - - return ( -
- - -
- ); -} - -function RenderTreeController() { - const editor = useEditor(); - const tracks = useEditor( - (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, - ); - const mediaAssets = useEditor((e) => e.media.getAssets()); - const activeProject = useEditor((e) => e.project.getActive()); - - const { width, height } = usePreviewSize(); - - useDeepCompareEffect(() => { - if (!activeProject) return; - - const duration = editor.timeline.getTotalDuration(); - const renderTree = buildScene({ - tracks, - mediaAssets, - duration, - canvasSize: { width, height }, - background: activeProject.settings.background, - isPreview: true, - }); - - editor.renderer.setRenderTree({ renderTree }); - }, [tracks, mediaAssets, activeProject?.settings.background, width, height]); - - return null; -} - -function PreviewCanvas({ - containerRef, - onToggleFullscreen, -}: { - containerRef: React.RefObject; - onToggleFullscreen: () => void; -}) { - const canvasRef = useRef(null); - const viewportRef = useRef(null); - const lastFrameRef = useRef(-1); - const lastSceneRef = useRef(null); - const renderingRef = useRef(false); - const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); - const viewportSize = useContainerSize({ containerRef: viewportRef }); - const editor = useEditor(); - 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({ - width: nativeWidth, - height: nativeHeight, - fps: activeProject.settings.fps, - }); - }, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]); - - const render = useCallback(() => { - if (canvasRef.current && renderTree && !renderingRef.current) { - const renderTime = Math.min( - editor.playback.getCurrentTime(), - editor.timeline.getLastFrameTime(), - ); - const ticksPerFrame = Math.round(TICKS_PER_SECOND * renderer.fps.denominator / renderer.fps.numerator); - const frame = Math.floor(renderTime / ticksPerFrame); - - if ( - frame !== lastFrameRef.current || - renderTree !== lastSceneRef.current - ) { - renderingRef.current = true; - lastSceneRef.current = renderTree; - lastFrameRef.current = frame; - renderer - .renderToCanvas({ - node: renderTree, - time: renderTime, - targetCanvas: canvasRef.current, - }) - .then(() => { - renderingRef.current = false; - }); - } - } - }, [renderer, renderTree, editor.playback]); - - useRafLoop(render); - - useEffect(() => { - const container = viewportRef.current; - if (!container) return; - - let pendingZoomDelta = 0; - let pendingPanDeltaX = 0; - let pendingPanDeltaY = 0; - let zoomRafId: ReturnType | null = null; - let panRafId: ReturnType | 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 ( - -
-
- - -
- - - - {overlays.bookmarks && } -
-
- -
-
- -
-
- ); -} diff --git a/apps/web/src/components/editor/panels/preview/mask-handles.tsx b/apps/web/src/components/editor/panels/preview/mask-handles.tsx deleted file mode 100644 index d32b6ff9..00000000 --- a/apps/web/src/components/editor/panels/preview/mask-handles.tsx +++ /dev/null @@ -1,308 +0,0 @@ -"use client"; - -import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useMaskHandles } from "@/hooks/use-mask-handles"; -import type { SnapLine } from "@/lib/preview/preview-snap"; -import { - CornerHandle, - CircleHandle, - CanvasPathOutline, - EdgeHandle, - IconHandle, - LineOverlay, - BoundingBoxOutline, - ShapeOutline, -} from "./handle-primitives"; - -const CUSTOM_MASK_ANCHOR_SIZE = 7; -const CUSTOM_MASK_TANGENT_SIZE = 6; -import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons"; - -export function MaskHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: (lines: SnapLine[]) => void; -}) { - const viewport = usePreviewViewport(); - const { - selectedWithMask, - handlePositions, - overlays, - isCreatingCustomMask, - handleCanvasPointerDown, - handlePointerDown, - handlePointerMove, - handlePointerUp, - } = useMaskHandles({ onSnapLinesChange }); - - if (!selectedWithMask) return null; - - const toOverlay = ({ - canvasX, - canvasY, - }: { - canvasX: number; - canvasY: number; - }) => - viewport.canvasToOverlay({ - canvasX, - canvasY, - }); - - const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); - const canvasOrigin = toOverlay({ canvasX: 0, canvasY: 0 }); - - const onPointerMove = (event: React.PointerEvent) => { - if (viewport.handlePanPointerMove({ event })) { - return; - } - - handlePointerMove({ event }); - }; - const onPointerUp = (event: React.PointerEvent) => { - if (viewport.handlePanPointerUp({ event })) { - return; - } - - handlePointerUp(); - }; - const handleMaskPointerDown = ({ - event, - handleId, - }: { - event: React.PointerEvent; - handleId: string; - }) => { - if (viewport.handlePanPointerDown({ event })) { - return; - } - - handlePointerDown({ event, handleId }); - }; - const handleCanvasOverlayPointerDown = (event: React.PointerEvent) => { - if (viewport.handlePanPointerDown({ event })) { - return; - } - - handleCanvasPointerDown({ event }); - }; - - return ( -
- {isCreatingCustomMask ? ( -
- ) : null} - {overlays.map((overlay) => { - const overlayHandleId = overlay.handleId; - const pointerHandlers = overlayHandleId - ? { - onPointerDown: (event: React.PointerEvent) => - handleMaskPointerDown({ event, handleId: overlayHandleId }), - onPointerMove, - onPointerUp, - } - : {}; - - if (overlay.type === "line") { - return ( - - ); - } - - if (overlay.type === "rect") { - return ( - - ); - } - - if (overlay.type === "shape") { - return ( - - ); - } - - if (overlay.type === "canvas-path") { - return ( - - ); - } - - return null; - })} - {handlePositions.map((handle) => { - const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); - - if (handle.kind === "icon" && handle.icon === "rotate") { - return ( - - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - if (handle.kind === "icon" && handle.icon === "feather") { - return ( - - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - if (handle.kind === "edge" && handle.edgeAxis === "horizontal") { - return ( - - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - if (handle.kind === "edge" && handle.edgeAxis === "vertical") { - return ( - - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - if (handle.kind === "point" || handle.kind === "tangent") { - return ( - - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - if (handle.kind === "corner") { - return ( - - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } - - return ( - - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - })} -
- ); -} 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 deleted file mode 100644 index 6ee4b584..00000000 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ /dev/null @@ -1,103 +0,0 @@ -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([]); - 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; - - const { - onPointerDown, - onPointerMove, - onPointerUp, - onDoubleClick, - editingText, - commitTextEdit, - } = 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); - }; - - return ( -
-
e.preventDefault()} - /> - {editingText ? ( - - ) : isMaskMode ? ( - - ) : ( - - )} - -
- ); -} diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx deleted file mode 100644 index 2ac6bd92..00000000 --- a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx +++ /dev/null @@ -1,576 +0,0 @@ -"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 "@/lib/preview/zoom"; - -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(null); - -interface PreviewViewportProviderProps { - children: React.ReactNode; - value: PreviewViewportContextValue; -} - -interface PreviewViewportStateOptions { - canvasHeight: number; - canvasWidth: number; - viewportHeight: number; - viewportRef: React.RefObject; - 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 ( - - {children} - - ); -} - -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(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 deleted file mode 100644 index ced5e921..00000000 --- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import type { SnapLine } from "@/lib/preview/preview-snap"; - -export function SnapGuides({ lines }: { lines: SnapLine[] }) { - const viewport = usePreviewViewport(); - - if (lines.length === 0) { - return null; - } - - const toOverlayX = (logicalX: number) => - viewport.positionToOverlay({ - positionX: logicalX, - positionY: 0, - }).x; - - const toOverlayY = (logicalY: number) => - viewport.positionToOverlay({ - positionX: 0, - positionY: logicalY, - }).y; - - return ( -
- {lines.map((line) => { - if (line.type === "vertical") { - return ( -
- ); - } - 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 deleted file mode 100644 index eabbde8a..00000000 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ /dev/null @@ -1,158 +0,0 @@ -"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 "@/lib/timeline"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - getElementLocalTime, - resolveTransformAtTime, -} from "@/lib/animation"; -import { resolveTextLayout } from "@/lib/text/primitives"; - -export function TextEditOverlay({ - trackId, - elementId, - element, - onCommit, -}: { - trackId: string; - elementId: string; - element: TextElement; - onCommit: () => void; -}) { - const editor = useEditor(); - const viewport = usePreviewViewport(); - const divRef = useRef(null); - - useEffect(() => { - const div = divRef.current; - if (!div) return; - div.focus(); - const range = document.createRange(); - range.selectNodeContents(div); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }, []); - - const handleInput = useCallback(() => { - const div = divRef.current; - if (!div) return; - const text = div.innerText; - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: { content: text } }], - }); - }, [editor.timeline, trackId, elementId]); - - const handleKeyDown = useCallback( - ({ event }: { event: React.KeyboardEvent }) => { - const { key } = event; - if (key === "Escape") { - event.preventDefault(); - onCommit(); - return; - } - }, - [onCommit], - ); - - const canvasSize = editor.project.getActive().settings.canvasSize; - - if (!canvasSize) return null; - - const currentTime = editor.playback.getCurrentTime(); - const localTime = getElementLocalTime({ - timelineTime: currentTime, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const transform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const { x: posX, y: posY } = viewport.positionToOverlay({ - positionX: transform.position.x, - positionY: transform.position.y, - }); - - const { x: displayScaleX } = viewport.getDisplayScale(); - const resolvedTextLayout = resolveTextLayout({ - text: { - content: element.content, - fontSize: element.fontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textAlign: element.textAlign, - textDecoration: element.textDecoration, - letterSpacing: element.letterSpacing, - lineHeight: element.lineHeight, - }, - canvasHeight: canvasSize.height, - }); - - const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; - const canvasLetterSpacing = element.letterSpacing ?? 0; - const lineHeightPx = resolvedTextLayout.lineHeightPx; - - const bg = element.background; - const shouldShowBackground = - bg.enabled && bg.color && bg.color !== "transparent"; - const fontSizeRatio = resolvedTextLayout.fontSizeRatio; - const canvasPaddingX = shouldShowBackground - ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio - : 0; - const canvasPaddingY = shouldShowBackground - ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio - : 0; - - return ( -
- {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} -
handleKeyDown({ event })} - > - {element.content || ""} -
-
- ); -} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx deleted file mode 100644 index 9ee6c822..00000000 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { formatTimecode } from "opencut-wasm"; -import { invokeAction } from "@/lib/actions"; -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 { 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 "@/lib/preview/zoom"; -import { usePreviewViewport } from "./preview-viewport"; -import { GridPopover } from "./guide-popover"; -import { usePreviewStore } from "@/stores/preview-store"; - -export function PreviewToolbar({ - onToggleFullscreen, -}: { - onToggleFullscreen: () => void; -}) { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const activeGuideDefinition = getGuideById(activeGuide); - - return ( -
- - -
- - - {/* v0.4.0 */} - {/* - - */} - -
-
- ); -} - -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 ( -
- editor.playback.seek({ time })} - className="text-center" - /> - / - - {formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })} - -
- ); -} - -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 ( - - ); -} - -function PlayPauseButton() { - const isPlaying = useEditor((e) => e.playback.getIsPlaying()); - - return ( - - ); -} diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx deleted file mode 100644 index 2b81b557..00000000 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useTransformHandles } from "@/hooks/use-transform-handles"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { - getCornerPosition, - getEdgeHandlePosition, - ROTATION_HANDLE_OFFSET, - 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 CORNERS: Corner[] = [ - "top-left", - "top-right", - "bottom-left", - "bottom-right", -]; -const EDGES: Edge[] = ["right", "left", "bottom"]; - -export function TransformHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: OnSnapLinesChange; -}) { - const viewport = usePreviewViewport(); - const { - selectedWithBounds, - hasVisualSelection, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, - } = useTransformHandles({ onSnapLinesChange }); - - if (!hasVisualSelection || !selectedWithBounds) return null; - - const { bounds, element } = selectedWithBounds; - if (!isVisualElement(element)) return null; - - const displayScale = viewport.getDisplayScale(); - - const toOverlay = ({ - canvasX, - canvasY, - }: { - canvasX: number; - canvasY: number; - }) => - viewport.canvasToOverlay({ - canvasX, - canvasY, - }); - - const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); - const outlineWidth = Math.abs(bounds.width) * displayScale.x; - const outlineHeight = Math.abs(bounds.height) * displayScale.y; - - const rotationAngleRad = (bounds.rotation * Math.PI) / 180; - const topCenterLocalY = -bounds.height / 2; - const topCenterScreen = toOverlay({ - canvasX: bounds.cx - topCenterLocalY * Math.sin(rotationAngleRad), - canvasY: bounds.cy + topCenterLocalY * Math.cos(rotationAngleRad), - }); - const rotationHandleScreen = { - x: topCenterScreen.x + Math.sin(rotationAngleRad) * ROTATION_HANDLE_OFFSET, - y: topCenterScreen.y - Math.cos(rotationAngleRad) * ROTATION_HANDLE_OFFSET, - }; - - const onPointerMove = (event: React.PointerEvent) => - handlePointerMove({ event }); - const onPointerUp = () => handlePointerUp(); - - return ( -
- - {CORNERS.map((corner) => { - const cornerPosition = getCornerPosition({ bounds, corner }); - const screen = toOverlay({ - canvasX: cornerPosition.x, - canvasY: cornerPosition.y, - }); - const angleDeg = - Math.atan2(screen.y - center.y, screen.x - center.x) * - (180 / Math.PI); - return ( - - handleCornerPointerDown({ event, corner }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - })} - {EDGES.map((edge) => { - const edgePosition = getEdgeHandlePosition({ bounds, edge }); - const screen = toOverlay({ - canvasX: edgePosition.x, - canvasY: edgePosition.y, - }); - return ( - handleEdgePointerDown({ event, edge })} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - })} - handleRotationPointerDown({ event })} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> -
- ); -} 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 index 9cc2b02e..c9a9c22e 100644 --- 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 @@ -1,6 +1,6 @@ "use client"; -import type { ParamDefinition, NumberParamDefinition } from "@/lib/params"; +import type { ParamDefinition, NumberParamDefinition } from "@/params"; import { formatNumberForDisplay, getFractionDigitsForStep, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 27300784..967156b3 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -1,5 +1,5 @@ -import { useEditor } from "@/hooks/use-editor"; -import { getElementLocalTime } from "@/lib/animation"; +import { useEditor } from "@/editor/use-editor"; +import { getElementLocalTime } from "@/animation"; export function useElementPlayhead({ startTime, 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 a42e422f..734b514f 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 @@ -1,11 +1,11 @@ -import { useEditor } from "@/hooks/use-editor"; +import { useEditor } from "@/editor/use-editor"; import { getKeyframeAtTime, hasKeyframesForPath, upsertElementKeyframe, -} from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/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 872d55c0..9dd39768 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 @@ -1,11 +1,11 @@ -import { useEditor } from "@/hooks/use-editor"; +import { useEditor } from "@/editor/use-editor"; import { getKeyframeAtTime, hasKeyframesForPath, upsertElementKeyframe, -} from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; +} from "@/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; import { snapToStep } from "@/utils/math"; import { usePropertyDraft } from "./use-property-draft"; 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 index 29b290d9..b0b6f01a 100644 --- 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 @@ -1,6 +1,6 @@ "use client"; -import { useEditor } from "@/hooks/use-editor"; +import { useEditor } from "@/editor/use-editor"; import { buildGraphicParamPath, coerceAnimationValueForParam, @@ -9,12 +9,12 @@ import { getParamValueKind, hasKeyframesForPath, upsertPathKeyframe, -} from "@/lib/animation"; +} from "@/animation"; import type { ElementAnimations, -} from "@/lib/animation/types"; -import type { ParamDefinition } from "@/lib/params"; -import type { TimelineElement } from "@/lib/timeline"; +} from "@/animation/types"; +import type { ParamDefinition } from "@/params"; +import type { TimelineElement } from "@/timeline"; export interface KeyframedParamPropertyResult { hasAnimatedKeyframes: boolean; diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 75319ef3..52fa42b5 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -8,8 +8,8 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useEditor } from "@/hooks/use-editor"; -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { useEditor } from "@/editor/use-editor"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { usePropertiesStore } from "./stores/properties-store"; import { getPropertiesConfig } from "./registry"; import { cn } from "@/utils/ui"; diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index df6c0e52..f9e40ab3 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -11,8 +11,8 @@ import type { VideoElement, AudioElement, TimelineElement, -} from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; +} from "@/timeline"; +import type { MediaAsset } from "@/media/types"; import { HugeiconsIcon } from "@hugeicons/react"; import { TextFontIcon, @@ -22,14 +22,14 @@ import { 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 { TransformTab } from "@/rendering/components/transform-tab"; +import { BlendingTab } from "@/rendering/components/blending-tab"; +import { AudioTab } from "@/timeline/components/audio-tab"; +import { TextTab } from "@/text/components/text-tab"; +import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab"; +import { MasksTab } from "@/masks/components/masks-tab"; +import { SpeedTab } from "@/speed/components/speed-tab"; +import { GraphicTab } from "@/graphics/components/graphic-tab"; import { OcShapesIcon } from "@/components/icons"; export type TabContentProps = { 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 deleted file mode 100644 index 5ac0fbb7..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { NumberField } from "@/components/ui/number-field"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - clamp, - formatNumberForDisplay, - getFractionDigitsForStep, - isNearlyEqual, - snapToStep, -} from "@/utils/math"; -import type { AudioElement, VideoElement } from "@/lib/timeline"; -import { resolveNumberAtTime } from "@/lib/animation"; -import { useEditor } from "@/hooks/use-editor"; -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"; - -const VOLUME_STEP = 0.1; -const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); - -export function AudioTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const editor = useEditor(); - 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: formatNumberForDisplay({ - value: resolvedVolume, - fractionDigits: VOLUME_FRACTION_DIGITS, - }), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) { - return null; - } - - return clamp({ - value: snapToStep({ value: parsed, step: VOLUME_STEP }), - min: VOLUME_DB_MIN, - max: VOLUME_DB_MAX, - }); - }, - valueAtPlayhead: resolvedVolume, - step: VOLUME_STEP, - buildBaseUpdates: ({ value }) => ({ - volume: value, - }), - }); - const isDefault = - volume.hasAnimatedKeyframes && isPlayheadWithinElementRange - ? isNearlyEqual({ - leftValue: resolvedVolume, - rightValue: DEFAULTS.element.volume, - }) - : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; - const isSeparated = - element.type === "video" && isSourceAudioSeparated({ element }); - - return ( - <> - {isSeparated && ( -
-

Audio has been separated.

- -
- )} -
- - Audio - - - - - } - > - } - 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" - /> - - - -
- - ); -} diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx deleted file mode 100644 index f8e5397d..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { useEditor } from "@/hooks/use-editor"; -import { clamp } from "@/utils/math"; -import { NumberField } from "@/components/ui/number-field"; -import { OcCheckerboardIcon } from "@/components/icons"; -import { Fragment, useRef } from "react"; -import { useMenuPreview } from "@/hooks/use-menu-preview"; -import { - Section, - SectionContent, - SectionField, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -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 "../components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/lib/animation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { isPropertyAtDefault } from "./transform-tab"; - -type BlendingElement = { - id: string; - opacity: number; - type: ElementType; - blendMode?: BlendMode; - startTime: number; - duration: number; - animations?: ElementAnimations; -}; - -const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [ - [{ value: "normal", label: "Normal" }], - [ - { value: "darken", label: "Darken" }, - { value: "multiply", label: "Multiply" }, - { value: "color-burn", label: "Color Burn" }, - ], - [ - { value: "lighten", label: "Lighten" }, - { value: "screen", label: "Screen" }, - { value: "plus-lighter", label: "Plus Lighter" }, - { value: "color-dodge", label: "Color Dodge" }, - ], - [ - { value: "overlay", label: "Overlay" }, - { value: "soft-light", label: "Soft Light" }, - { value: "hard-light", label: "Hard Light" }, - ], - [ - { value: "difference", label: "Difference" }, - { value: "exclusion", label: "Exclusion" }, - ], - [ - { value: "hue", label: "Hue" }, - { value: "saturation", label: "Saturation" }, - { value: "color", label: "Color" }, - { value: "luminosity", label: "Luminosity" }, - ], -]; - -export function BlendingTab({ - element, - trackId, -}: { - element: BlendingElement; - trackId: string; -}) { - const editor = useEditor(); - const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); - const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; - const committedBlendModeRef = useRef(blendMode); - if (!isPreviewActive) { - committedBlendModeRef.current = blendMode; - } - - const { - onPointerLeave, - onOpenChange: handleBlendModeOpenChange, - markCommitted, - } = useMenuPreview(); - - const previewBlendMode = ({ value }: { value: BlendMode }) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { blendMode: value } }, - ], - }); - - const commitBlendMode = (value: BlendMode) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { blendMode: value }, - }, - ], - }); - } - markCommitted(); - }; - - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedOpacity = resolveOpacityAtTime({ - baseOpacity: element.opacity, - animations: element.animations, - localTime, - }); - - const opacity = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "opacity", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOpacity * 100).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min: 0, max: 100 }) / 100; - }, - valueAtPlayhead: resolvedOpacity, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ opacity: value }), - }); - - return ( -
- - Blending - - -
- - } - > - - } - 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" - /> - - - - -
-
-
- ); -} 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 deleted file mode 100644 index 1b94ee2d..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx +++ /dev/null @@ -1,309 +0,0 @@ -"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 ( -
-
- Effect -
- -
- ); -} - -export function ClipEffectsTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const [dragIndex, setDragIndex] = useState(null); - const [dropIndex, setDropIndex] = useState(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 ( -
-
- Effects -
- {effects.length === 0 ? ( - - ) : ( -
    - {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 ( -
  • 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", - )} - > - - editor.timeline.toggleClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - onRemove={() => - editor.timeline.removeClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - /> -
  • - ); - })} -
- )} -
- ); -} - -function EmptyView() { - const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab); - - return ( -
- -
-

No effects

-

- Add effects to this layer from the Assets panel. -

-
- -
- ); -} - -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 ( -
- - - -
- ) - } - > - - {definition.name} - - - - - {definition.params.map((param) => ( -
-
- -
- -
- ))} -
-
- - ); -} 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 deleted file mode 100644 index c61c9cd7..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx +++ /dev/null @@ -1,238 +0,0 @@ -"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 ( -
-
- - {definition.name} - - - - {shapeParams.map((param) => ( - - ))} - - -
- {hasStrokeParams && ( - - )} -
- ); -} - -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, - patch: { params: { ...element.params, strokeWidth: 0 } }, - }, - ], - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - params: { - ...element.params, - strokeWidth: lastStrokeWidth.current, - }, - }, - }, - ], - }); - } - }; - - return ( -
- { - event.stopPropagation(); - toggleStroke(); - }} - > - - - } - > - Stroke - - - - {strokeParams.map((param) => ( - - ))} - - -
- ); -} - -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 ( - - ); -} 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 deleted file mode 100644 index 090fce64..00000000 --- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx +++ /dev/null @@ -1,826 +0,0 @@ -"use client"; - -import type { MaskableElement } from "@/lib/timeline"; -import type { Mask, MaskType, TextMask } 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 { - ArrowExpandIcon, - Delete02Icon, - FeatherIcon, - PlusSignIcon, - RotateClockwiseIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; -import { useMemo, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { FontPicker } from "@/components/ui/font-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 { Textarea } from "@/components/ui/textarea"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - clamp, - formatNumberForDisplay, - getFractionDigitsForStep, - snapToStep, -} from "@/utils/math"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { usePropertyDraft } from "../hooks/use-property-draft"; -import { - OcMirrorIcon, - OcShapesIcon, - OcTextHeightIcon, - OcTextWidthIcon, -} from "@/components/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"]>; - -function isTextMask(mask: Mask): mask is TextMask { - return mask.type === "text"; -} - -export function MasksTab({ element, trackId }: MasksTabProps) { - const editor = useEditor(); - const { renderElement, previewUpdates, commit } = - useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - const maskDefs = masksRegistry.getAll(); - const tracks = useEditor( - (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, - ); - 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 - 1, - ); - - 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, - }, - ], - }); - }; - - const commitMask = ({ maskType }: { maskType: MaskType }) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - masks: [ - buildDefaultMaskInstance({ - maskType, - elementSize: elementBounds - ? { - width: elementBounds.width, - height: elementBounds.height, - } - : undefined, - }), - ], - } as Partial, - }, - ], - }); - } - 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); - }; - - return ( -
-
- Masks - - {hasMask ? ( - - - - - - - - Only one mask is supported right now. If you need more, - duplicate the clip and apply a different mask to each copy. - - - ) : ( - - - - )} - - {maskDefs.map((definition) => ( - - previewMask({ maskType: definition.type }) - } - onClick={() => commitMask({ maskType: definition.type })} - > - - {definition.name} - - ))} - - -
- - {masks.length === 0 ? ( - setIsDropdownOpen(true)} /> - ) : ( - masks.map((mask, index) => ( - - previewMaskParam({ index, key: paramKey }) - } - onCommit={commit} - /> - )) - )} -
- ); -} - -function MaskItem({ - trackId, - elementId, - mask, - previewParam, - onCommit, -}: MaskItemProps) { - const editor = useEditor(); - const definition = masksRegistry.get(mask.type); - - return ( -
- - - -
- } - > -
- - - {definition.name} - -
- - - - - - ); -} - -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 ( - - {isTextMask(mask) ? ( - - ) : null} - {definition.features.hasPosition && - "centerX" in mask.params && - "centerY" in mask.params && ( - -
- - -
-
- )} - - {definition.features.sizeMode === "width-height" && - "width" in mask.params && - "height" in mask.params && ( - -
- - -
-
- )} - - {definition.features.sizeMode === "height-only" && - "height" in mask.params && ( - - - - )} - - {definition.features.sizeMode === "width-only" && - "width" in mask.params && ( - - - - )} - - {definition.features.sizeMode === "uniform" && "scale" in mask.params && ( - - : "S" - } - param={getNumberParamDefinition({ - definition, - key: "scale", - })} - value={getMaskNumber({ - params: mask.params, - key: "scale", - })} - onPreview={previewNumberParam("scale")} - onCommit={onCommit} - /> - - )} - - {definition.features.hasRotation && "rotation" in mask.params && ( - - } - param={getNumberParamDefinition({ - definition, - key: "rotation", - })} - value={getMaskNumber({ - params: mask.params, - key: "rotation", - })} - onPreview={previewNumberParam("rotation")} - onCommit={onCommit} - /> - - )} - - - } - param={featherParam} - value={getMaskNumber({ - params: mask.params, - key: "feather", - })} - onPreview={previewNumberParam("feather")} - onCommit={onCommit} - /> - - - -
-
- - previewStrokeColor(`#${color}`)} - onChangeEnd={(color) => { - previewStrokeColor(`#${color}`); - onCommit(); - }} - /> -
- {strokeAlignParam ? ( - - ) : null} -
-
-
- ); -} - -const LETTER_SPACING_PARAM: NumberParamDefinition = { - key: "letterSpacing", - label: "Letter spacing", - type: "number", - default: 0, - min: -100, - max: 500, - step: 1, -}; - -const LINE_HEIGHT_PARAM: NumberParamDefinition = { - key: "lineHeight", - label: "Line height", - type: "number", - default: 1.2, - min: 0.1, - max: 10, - step: 0.1, -}; - -function TextMaskFields({ - mask, - previewParam, - onCommit, - fontSizeParam, -}: { - mask: TextMask; - previewParam: PreviewParamHandler; - onCommit: () => void; - fontSizeParam: NumberParamDefinition; -}) { - const content = usePropertyDraft({ - displayValue: mask.params.content, - parse: (input) => input, - onPreview: (value) => previewParam("content")(value), - onCommit, - }); - - const previewNumberParam = (key: string) => (value: number) => - previewParam(key)(value); - - return ( - <> - -