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 --- 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 - 284 files changed, 32846 deletions(-) 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 (limited to 'apps/web/src/lib') diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts deleted file mode 100644 index f6846846..00000000 --- a/apps/web/src/lib/actions/definitions.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { - KeybindingConfig, - ShortcutKey, -} from "@/lib/actions/keybinding"; -import type { TActionWithOptionalArgs } from "./types"; - -export type TActionCategory = - | "playback" - | "navigation" - | "editing" - | "selection" - | "history" - | "timeline" - | "controls" - | "assets"; - -export interface TActionBaseDefinition { - description: string; - category: TActionCategory; - args?: Record; -} - -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/lib/actions/index.ts b/apps/web/src/lib/actions/index.ts deleted file mode 100644 index 801a846a..00000000 --- a/apps/web/src/lib/actions/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./definitions"; -export * from "./types"; -export * from "./registry"; diff --git a/apps/web/src/lib/actions/keybinding.ts b/apps/web/src/lib/actions/keybinding.ts deleted file mode 100644 index 65ef4538..00000000 --- a/apps/web/src/lib/actions/keybinding.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { TActionWithOptionalArgs } from "./types"; - -/** - * Alt is also regarded as macOS OPTION (⌥) key - * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) - */ -export type ModifierKeys = - | "ctrl" - | "alt" - | "shift" - | "ctrl+shift" - | "alt+shift" - | "ctrl+alt" - | "ctrl+alt+shift"; - -export type Key = - | "a" - | "b" - | "c" - | "d" - | "e" - | "f" - | "g" - | "h" - | "i" - | "j" - | "k" - | "l" - | "m" - | "n" - | "o" - | "p" - | "q" - | "r" - | "s" - | "t" - | "u" - | "v" - | "w" - | "x" - | "y" - | "z" - | "0" - | "1" - | "2" - | "3" - | "4" - | "5" - | "6" - | "7" - | "8" - | "9" - | "up" - | "down" - | "left" - | "right" - | "/" - | "?" - | "." - | "enter" - | "tab" - | "space" - | "escape" - | "esc" - | "backspace" - | "delete" - | "home" - | "end"; -/* eslint-enable */ - -export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; -// Singular keybindings (these will be disabled when an input-ish area has been focused) -export type SingleCharacterShortcutKey = `${Key}`; - -export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; - -export type KeybindingConfig = { - [key in ShortcutKey]?: TActionWithOptionalArgs; -}; diff --git a/apps/web/src/lib/actions/registry.ts b/apps/web/src/lib/actions/registry.ts deleted file mode 100644 index e69b3b9f..00000000 --- a/apps/web/src/lib/actions/registry.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { - TAction, - TActionFunc, - TActionWithArgs, - TActionWithOptionalArgs, - TActionArgsMap, - TArgOfAction, - TInvocationTrigger, -} from "./types"; - -type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void; -const boundActions: Partial> = {}; - -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/lib/actions/types.ts b/apps/web/src/lib/actions/types.ts deleted file mode 100644 index 41258c7b..00000000 --- a/apps/web/src/lib/actions/types.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { MutableRefObject } from "react"; -import type { TAction } from "./definitions"; - -export type { TAction }; - -export type TActionArgsMap = { - "seek-forward": { seconds: number } | undefined; - "seek-backward": { seconds: number } | undefined; - "jump-forward": { seconds: number } | undefined; - "jump-backward": { seconds: number } | undefined; - "remove-media-asset": { projectId: string; assetId: string }; - "remove-media-assets": { projectId: string; assetIds: string[] }; -}; - -type TKeysWithValueUndefined = { - [K in keyof T]: undefined extends T[K] ? K : never; -}[keyof T]; - -export type TActionWithArgs = keyof TActionArgsMap; - -export type TActionWithOptionalArgs = - | TActionWithNoArgs - | TKeysWithValueUndefined; - -export type TActionWithNoArgs = Exclude; - -export type TArgOfAction = A extends TActionWithArgs - ? TActionArgsMap[A] - : undefined; - -export type TActionFunc = A extends TActionWithArgs - ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void - : (_?: undefined, trigger?: TInvocationTrigger) => void; - -export type TInvocationTrigger = "keypress" | "mouseclick"; - -export type TBoundActionList = { - [A in TAction]?: Array>; -}; - -export type TActionHandlerOptions = - | MutableRefObject - | boolean - | undefined; diff --git a/apps/web/src/lib/animation/__tests__/binding-values.test.ts b/apps/web/src/lib/animation/__tests__/binding-values.test.ts deleted file mode 100644 index c5a354e4..00000000 --- a/apps/web/src/lib/animation/__tests__/binding-values.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - composeAnimationValue, - createAnimationBinding, -} from "@/lib/animation/binding-values"; - -describe("binding values", () => { - test("formats composed animated colors as hex", () => { - const binding = createAnimationBinding({ - path: "color", - kind: "color", - }); - - expect( - composeAnimationValue({ - binding, - componentValues: { - r: 1, - g: 0, - b: 0, - a: 1, - }, - }), - ).toBe("#ff0000"); - }); -}); diff --git a/apps/web/src/lib/animation/bezier.ts b/apps/web/src/lib/animation/bezier.ts deleted file mode 100644 index 5a9758ed..00000000 --- a/apps/web/src/lib/animation/bezier.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { ScalarAnimationKey } from "@/lib/animation/types"; - -const BEZIER_SOLVE_ITERATIONS = 20; - -export function getBezierPoint({ - progress, - p0, - p1, - p2, - p3, -}: { - progress: number; - p0: number; - p1: number; - p2: number; - p3: number; -}) { - const mt = 1 - progress; - return ( - mt * mt * mt * p0 + - 3 * mt * mt * progress * p1 + - 3 * mt * progress * progress * p2 + - progress * progress * progress * p3 - ); -} - -export function getDefaultRightHandle({ - leftKey, - rightKey, -}: { - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; -}) { - const span = rightKey.time - leftKey.time; - const valueDelta = rightKey.value - leftKey.value; - return { - dt: span / 3, - dv: valueDelta / 3, - }; -} - -export function getDefaultLeftHandle({ - leftKey, - rightKey, -}: { - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; -}) { - const span = rightKey.time - leftKey.time; - const valueDelta = rightKey.value - leftKey.value; - return { - dt: -span / 3, - dv: -valueDelta / 3, - }; -} - -export function solveBezierProgressForTime({ - time, - leftKey, - rightKey, -}: { - time: number; - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; -}) { - let lower = 0; - let upper = 1; - const rightHandle = - leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); - const leftHandle = - rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); - - for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) { - const mid = (lower + upper) / 2; - const estimate = getBezierPoint({ - progress: mid, - p0: leftKey.time, - p1: leftKey.time + rightHandle.dt, - p2: rightKey.time + leftHandle.dt, - p3: rightKey.time, - }); - if (estimate < time) { - lower = mid; - } else { - upper = mid; - } - } - - return (lower + upper) / 2; -} diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts deleted file mode 100644 index 66fb2b5d..00000000 --- a/apps/web/src/lib/animation/binding-values.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { converter, formatHex, formatHex8, parse } from "culori"; -import type { - AnimationBindingComponent, - AnimationBindingOfKind, - AnimationBindingInstance, - AnimationBindingKind, - ColorAnimationBinding, - DiscreteAnimationBinding, - NumberAnimationBinding, - AnimationPath, - AnimationValue, - DiscreteValue, - Vector2AnimationBinding, - VectorValue, -} from "@/lib/animation/types"; -import { clamp } from "@/utils/math"; - -interface LinearRgba { - r: number; - g: number; - b: number; - a: number; -} - -export type AnimationComponentValue = number | DiscreteValue; - -const toRgb = converter("rgb"); - -function srgbToLinear({ value }: { value: number }): number { - return value <= 0.04045 - ? value / 12.92 - : Math.pow((value + 0.055) / 1.055, 2.4); -} - -function linearToSrgb({ value }: { value: number }): number { - const clamped = clamp({ value, min: 0, max: 1 }); - return clamped <= 0.0031308 - ? clamped * 12.92 - : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; -} - -function isRecord(value: unknown): value is Record { - 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/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts deleted file mode 100644 index 385fb448..00000000 --- a/apps/web/src/lib/animation/curve-bridge.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - getDefaultLeftHandle, - getDefaultRightHandle, -} from "@/lib/animation/bezier"; -import type { - CurveHandle, - NormalizedCubicBezier, - ScalarAnimationKey, -} from "@/lib/animation/types"; - -const VALUE_EPSILON = 1e-6; - -function clamp01({ value }: { value: number }): number { - return Math.max(0, Math.min(1, value)); -} - -export function getNormalizedCubicBezierForScalarSegment({ - leftKey, - rightKey, - referenceSpanValue, -}: { - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; - /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */ - referenceSpanValue?: number; -}): NormalizedCubicBezier | null { - const spanTime = rightKey.time - leftKey.time; - const spanValue = rightKey.value - leftKey.value; - const effectiveSpanValue = - Math.abs(spanValue) > VALUE_EPSILON - ? spanValue - : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON - ? referenceSpanValue - : null; - - if (spanTime === 0 || effectiveSpanValue === null) { - return null; - } - - const rightHandle = - leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); - const leftHandle = - rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); - - return [ - clamp01({ value: rightHandle.dt / spanTime }), - rightHandle.dv / effectiveSpanValue, - clamp01({ value: 1 + leftHandle.dt / spanTime }), - 1 + leftHandle.dv / effectiveSpanValue, - ]; -} - -export function getCurveHandlesForNormalizedCubicBezier({ - leftKey, - rightKey, - cubicBezier, - referenceSpanValue, -}: { - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; - cubicBezier: NormalizedCubicBezier; - /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */ - referenceSpanValue?: number; -}): { - rightHandle: CurveHandle; - leftHandle: CurveHandle; -} | null { - const spanTime = rightKey.time - leftKey.time; - const spanValue = rightKey.value - leftKey.value; - const effectiveSpanValue = - Math.abs(spanValue) > VALUE_EPSILON - ? spanValue - : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON - ? referenceSpanValue - : null; - - if (spanTime === 0 || effectiveSpanValue === null) { - return null; - } - - const [rawX1, y1, rawX2, y2] = cubicBezier; - const x1 = clamp01({ value: rawX1 }); - const x2 = clamp01({ value: rawX2 }); - - return { - rightHandle: { - dt: spanTime * x1, - dv: effectiveSpanValue * y1, - }, - leftHandle: { - dt: spanTime * (x2 - 1), - dv: effectiveSpanValue * (y2 - 1), - }, - }; -} diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts deleted file mode 100644 index 3080e6ff..00000000 --- a/apps/web/src/lib/animation/effect-param-channel.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { ParamValues } from "@/lib/params"; -import type { Effect } from "@/lib/effects/types"; -import type { - ElementAnimations, - EffectParamPath, -} from "@/lib/animation/types"; -import { removeElementKeyframe } from "./keyframes"; -import { resolveAnimationPathValueAtTime } from "./resolve"; - -export const EFFECT_PARAM_PATH_PREFIX = "effects."; -export const EFFECT_PARAM_PATH_SUFFIX = ".params."; - -export function buildEffectParamPath({ - effectId, - paramKey, -}: { - effectId: string; - paramKey: string; -}): EffectParamPath { - return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`; -} - -export function isEffectParamPath( - propertyPath: string, -): propertyPath is EffectParamPath { - return ( - propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) && - propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX) - ); -} - -export function parseEffectParamPath({ - propertyPath, -}: { - propertyPath: string; -}): { effectId: string; paramKey: string } | null { - if (!isEffectParamPath(propertyPath)) { - return null; - } - - const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length); - const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX); - if (separatorIndex <= 0) { - return null; - } - - const effectId = withoutPrefix.slice(0, separatorIndex); - const paramKey = withoutPrefix.slice( - separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length, - ); - if (!effectId || !paramKey) { - return null; - } - - return { effectId, paramKey }; -} - -export function resolveEffectParamsAtTime({ - effect, - animations, - localTime, -}: { - effect: Effect; - animations: ElementAnimations | undefined; - localTime: number; -}): ParamValues { - const resolved: ParamValues = {}; - - for (const [paramKey, staticValue] of Object.entries(effect.params)) { - const path = buildEffectParamPath({ effectId: effect.id, paramKey }); - resolved[paramKey] = animations?.bindings[path] - ? resolveAnimationPathValueAtTime({ - animations, - propertyPath: path, - localTime, - fallbackValue: staticValue, - }) - : staticValue; - } - - return resolved; -} - -export function removeEffectParamKeyframe({ - animations, - effectId, - paramKey, - keyframeId, -}: { - animations: ElementAnimations | undefined; - effectId: string; - paramKey: string; - keyframeId: string; -}): ElementAnimations | undefined { - return removeElementKeyframe({ - animations, - propertyPath: buildEffectParamPath({ effectId, paramKey }), - keyframeId, - }); -} diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts deleted file mode 100644 index 19d53e27..00000000 --- a/apps/web/src/lib/animation/graph-channels.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { - AnimationBindingInstance, - AnimationPath, - ElementAnimations, - ScalarAnimationChannel, - ScalarGraphChannel, - ScalarGraphKeyframeContext, -} from "@/lib/animation/types"; - -export interface EditableScalarChannels { - binding: AnimationBindingInstance; - channels: ScalarGraphChannel[]; -} - -function isScalarAnimationChannel( - channel: ElementAnimations["channels"][string], -): channel is ScalarAnimationChannel { - return channel?.kind === "scalar"; -} - -export function getEditableScalarChannels({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): EditableScalarChannels | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return null; - } - - const channels = binding.components.flatMap((component) => { - const channel = animations?.channels[component.channelId]; - if (!isScalarAnimationChannel(channel)) { - return []; - } - - return [ - { - propertyPath, - componentKey: component.key, - channelId: component.channelId, - channel, - } satisfies ScalarGraphChannel, - ]; - }); - - return { binding, channels }; -} - -export function getEditableScalarChannel({ - animations, - propertyPath, - componentKey, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - componentKey: string; -}): ScalarGraphChannel | null { - const result = getEditableScalarChannels({ animations, propertyPath }); - return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null; -} - -export function getScalarKeyframeContext({ - animations, - propertyPath, - componentKey, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - componentKey: string; - keyframeId: string; -}): ScalarGraphKeyframeContext | null { - const scalarChannel = getEditableScalarChannel({ - animations, - propertyPath, - componentKey, - }); - if (!scalarChannel) { - return null; - } - - const keyframeIndex = scalarChannel.channel.keys.findIndex( - (keyframe) => keyframe.id === keyframeId, - ); - if (keyframeIndex < 0) { - return null; - } - - return { - ...scalarChannel, - keyframe: scalarChannel.channel.keys[keyframeIndex], - keyframeIndex, - previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null, - nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null, - }; -} diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts deleted file mode 100644 index de595060..00000000 --- a/apps/web/src/lib/animation/graphic-param-channel.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { - ElementAnimations, - GraphicParamPath, -} from "@/lib/animation/types"; -import type { ParamValues } from "@/lib/params"; -import { - getGraphicDefinition, - resolveGraphicParams, -} from "@/lib/graphics"; -import { resolveAnimationPathValueAtTime } from "./resolve"; - -export const GRAPHIC_PARAM_PATH_PREFIX = "params."; - -export function buildGraphicParamPath({ - paramKey, -}: { - paramKey: string; -}): GraphicParamPath { - return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`; -} - -export function isGraphicParamPath( - propertyPath: string, -): propertyPath is GraphicParamPath { - return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX); -} - -export function parseGraphicParamPath({ - propertyPath, -}: { - propertyPath: string; -}): { paramKey: string } | null { - if (!isGraphicParamPath(propertyPath)) { - return null; - } - - const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length); - return paramKey.length > 0 ? { paramKey } : null; -} - -export function resolveGraphicParamsAtTime({ - element, - localTime, -}: { - element: { - definitionId: string; - params: ParamValues; - animations?: ElementAnimations; - }; - localTime: number; -}): ParamValues { - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const baseParams = resolveGraphicParams(definition, element.params); - const resolved: ParamValues = { ...baseParams }; - - for (const param of definition.params) { - const path = buildGraphicParamPath({ paramKey: param.key }); - if (!element.animations?.bindings[path]) { - continue; - } - - resolved[param.key] = resolveAnimationPathValueAtTime({ - animations: element.animations, - propertyPath: path, - localTime: Math.max(0, localTime), - fallbackValue: baseParams[param.key] ?? param.default, - }); - } - - return resolved; -} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts deleted file mode 100644 index 384f3d17..00000000 --- a/apps/web/src/lib/animation/index.ts +++ /dev/null @@ -1,96 +0,0 @@ -export { - getChannelValueAtTime, - getDiscreteChannelValueAtTime, - getScalarChannelValueAtTime, - getScalarSegmentInterpolation, - normalizeChannel, -} from "./interpolation"; - -export { - clampAnimationsToDuration, - cloneAnimations, - getChannel, - removeElementKeyframe, - retimeElementKeyframe, - setBindingComponentChannel, - setChannel, - splitAnimationsAtTime, - updateScalarKeyframeCurve, - upsertElementKeyframe, - upsertPathKeyframe, -} from "./keyframes"; - -export { - getElementLocalTime, - resolveAnimationPathValueAtTime, - resolveColorAtTime, - resolveNumberAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, -} from "./resolve"; - -export { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getDefaultInterpolationForProperty, - getElementBaseValueForProperty, - isAnimationPropertyPath, - supportsAnimationProperty, - type AnimationPropertyDefinition, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; - -export { - getElementKeyframes, - getKeyframeById, - getKeyframeAtTime, - hasKeyframesForPath, -} from "./keyframe-query"; - -export { - type EditableScalarChannels, - getEditableScalarChannel, - getEditableScalarChannels, - getScalarKeyframeContext, -} from "./graph-channels"; - -export { - getCurveHandlesForNormalizedCubicBezier, - getNormalizedCubicBezierForScalarSegment, -} from "./curve-bridge"; - -export { - buildGraphicParamPath, - isGraphicParamPath, - parseGraphicParamPath, - resolveGraphicParamsAtTime, -} from "./graphic-param-channel"; - -export { - buildEffectParamPath, - isEffectParamPath, - parseEffectParamPath, - removeEffectParamKeyframe, - resolveEffectParamsAtTime, -} from "./effect-param-channel"; - -export { - isAnimationPath, - coerceAnimationValueForParam, - resolveAnimationTarget, - getParamValueKind, - getParamDefaultInterpolation, - type AnimationPathDescriptor, -} from "./target-resolver"; - -export { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, - type GroupKeyframeRef, -} from "./property-groups"; - -export { - type EasingMode, - getEasingModeForKind, -} from "./binding-values"; diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts deleted file mode 100644 index 1a3c0fed..00000000 --- a/apps/web/src/lib/animation/interpolation.ts +++ /dev/null @@ -1,375 +0,0 @@ -import type { - AnimationChannel, - AnimationInterpolation, - AnimationValue, - DiscreteAnimationChannel, - DiscreteValue, - ScalarAnimationChannel, - ScalarAnimationKey, - ScalarSegmentType, -} from "@/lib/animation/types"; -import { clamp } from "@/utils/math"; -import { - getBezierPoint, - getDefaultLeftHandle, - getDefaultRightHandle, - solveBezierProgressForTime, -} from "./bezier"; - -function byTimeAscending({ - leftTime, - rightTime, -}: { - leftTime: number; - rightTime: number; -}): number { - return leftTime - rightTime; -} - -function isWithinTimePair({ - time, - leftTime, - rightTime, -}: { - time: number; - leftTime: number; - rightTime: number; -}): boolean { - return time >= leftTime && time <= rightTime; -} - -function lerpNumber({ - leftValue, - rightValue, - progress, -}: { - leftValue: number; - rightValue: number; - progress: number; -}): number { - return leftValue + (rightValue - leftValue) * progress; -} - -function normalizeRightHandle({ - handle, - leftKey, - rightKey, -}: { - handle: ScalarAnimationKey["rightHandle"]; - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; -}) { - if (!handle) { - return undefined; - } - - const span = Math.max(1, rightKey.time - leftKey.time); - return { - dt: Math.min(span, Math.max(0, handle.dt)), - dv: handle.dv, - }; -} - -function normalizeLeftHandle({ - handle, - leftKey, - rightKey, -}: { - handle: ScalarAnimationKey["leftHandle"]; - leftKey: ScalarAnimationKey; - rightKey: ScalarAnimationKey; -}) { - if (!handle) { - return undefined; - } - - const span = Math.max(1, rightKey.time - leftKey.time); - return { - dt: Math.max(-span, Math.min(0, handle.dt)), - dv: handle.dv, - }; -} - -function normalizeScalarKey({ - key, -}: { - key: ScalarAnimationKey; -}): ScalarAnimationKey { - return { - ...key, - tangentMode: key.tangentMode ?? "flat", - segmentToNext: key.segmentToNext ?? "linear", - }; -} - -function normalizeScalarChannel({ - channel, -}: { - channel: ScalarAnimationChannel; -}): ScalarAnimationChannel { - const sortedKeys = [...channel.keys] - .map((key) => normalizeScalarKey({ key })) - .sort((leftKey, rightKey) => - byTimeAscending({ - leftTime: leftKey.time, - rightTime: rightKey.time, - }), - ); - const nextKeys = sortedKeys.map((key, index) => { - const previousKey = sortedKeys[index - 1]; - const nextKey = sortedKeys[index + 1]; - return { - ...key, - leftHandle: - previousKey != null - ? normalizeLeftHandle({ - handle: key.leftHandle, - leftKey: previousKey, - rightKey: key, - }) - : undefined, - rightHandle: - nextKey != null - ? normalizeRightHandle({ - handle: key.rightHandle, - leftKey: key, - rightKey: nextKey, - }) - : undefined, - }; - }); - - return { - ...channel, - keys: nextKeys, - }; -} - -export function normalizeChannel({ - channel, -}: { - channel: TChannel; -}): TChannel { - if (channel.kind === "scalar") { - return normalizeScalarChannel({ - channel, - }) as TChannel; - } - - return { - ...channel, - keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => - byTimeAscending({ - leftTime: leftKeyframe.time, - rightTime: rightKeyframe.time, - }), - ), - } as TChannel; -} - -function extrapolateScalarEdge({ - mode, - edgeKey, - neighborKey, - time, -}: { - mode: "hold" | "linear"; - edgeKey: ScalarAnimationKey; - neighborKey: ScalarAnimationKey | undefined; - time: number; -}) { - if (mode === "hold" || !neighborKey) { - return edgeKey.value; - } - - const span = neighborKey.time - edgeKey.time; - if (span === 0) { - return edgeKey.value; - } - - return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value); -} - -export function getScalarSegmentInterpolation({ - segment, -}: { - segment: ScalarSegmentType; -}): AnimationInterpolation { - if (segment === "step") { - return "hold"; - } - - return segment === "bezier" ? "bezier" : "linear"; -} - -export function getScalarChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: ScalarAnimationChannel | undefined; - time: number; - fallbackValue: number; -}): number { - if (!channel || channel.keys.length === 0) { - return fallbackValue; - } - - const normalizedChannel = normalizeChannel({ - channel, - }); - const firstKey = normalizedChannel.keys[0]; - const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; - if (!firstKey || !lastKey) { - return fallbackValue; - } - - if (time <= firstKey.time) { - if (time < firstKey.time) { - return extrapolateScalarEdge({ - mode: normalizedChannel.extrapolation?.before ?? "hold", - edgeKey: firstKey, - neighborKey: normalizedChannel.keys[1], - time, - }); - } - - return firstKey.value; - } - - if (time >= lastKey.time) { - if (time > lastKey.time) { - return extrapolateScalarEdge({ - mode: normalizedChannel.extrapolation?.after ?? "hold", - edgeKey: lastKey, - neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2], - time, - }); - } - - return lastKey.value; - } - - for ( - let keyIndex = 0; - keyIndex < normalizedChannel.keys.length - 1; - keyIndex++ - ) { - const leftKey = normalizedChannel.keys[keyIndex]; - const rightKey = normalizedChannel.keys[keyIndex + 1]; - if (time === rightKey.time) { - return rightKey.value; - } - - if ( - !isWithinTimePair({ - time, - leftTime: leftKey.time, - rightTime: rightKey.time, - }) - ) { - continue; - } - - if (leftKey.segmentToNext === "step") { - return leftKey.value; - } - - const span = rightKey.time - leftKey.time; - if (span === 0) { - return rightKey.value; - } - - const progress = clamp({ - value: (time - leftKey.time) / span, - min: 0, - max: 1, - }); - if (leftKey.segmentToNext === "linear") { - return lerpNumber({ - leftValue: leftKey.value, - rightValue: rightKey.value, - progress, - }); - } - - const curveProgress = solveBezierProgressForTime({ - time, - leftKey, - rightKey, - }); - const rightHandle = - leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); - const leftHandle = - rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); - return getBezierPoint({ - progress: curveProgress, - p0: leftKey.value, - p1: leftKey.value + rightHandle.dv, - p2: rightKey.value + leftHandle.dv, - p3: rightKey.value, - }); - } - - return lastKey.value; -} - -export function getDiscreteChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: DiscreteAnimationChannel | undefined; - time: number; - fallbackValue: DiscreteValue; -}): DiscreteValue { - if (!channel || channel.keys.length === 0) { - return fallbackValue; - } - - const normalizedChannel = normalizeChannel({ - channel, - }); - let currentValue = fallbackValue; - for (const key of normalizedChannel.keys) { - if (time < key.time) { - break; - } - currentValue = key.value; - } - return currentValue; -} - -export function getChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: AnimationChannel | undefined; - time: number; - fallbackValue: AnimationValue; -}): AnimationValue { - if (!channel || channel.keys.length === 0) { - return fallbackValue; - } - - if (channel.kind === "scalar") { - return typeof fallbackValue === "number" - ? getScalarChannelValueAtTime({ - channel, - time, - fallbackValue, - }) - : fallbackValue; - } - - if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { - return fallbackValue; - } - - return getDiscreteChannelValueAtTime({ - channel, - time, - fallbackValue, - }); -} diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts deleted file mode 100644 index dbcd041c..00000000 --- a/apps/web/src/lib/animation/keyframe-query.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { - AnimationBindingInstance, - AnimationChannel, - AnimationPath, - ElementAnimations, - ElementKeyframe, -} from "@/lib/animation/types"; -import { - type AnimationComponentValue, - composeAnimationValue, -} from "./binding-values"; -import { - getChannelValueAtTime, - getScalarSegmentInterpolation, -} from "./interpolation"; -import { isAnimationPath } from "./target-resolver"; - -function getBindingFallbackValue({ - channel, -}: { - channel: ElementAnimations["channels"][string]; -}) { - if (!channel || channel.keys.length === 0) { - return channel?.kind === "discrete" ? false : 0; - } - - return channel.keys[0].value; -} - -interface BindingKeyframeMatch { - componentIndex: number; - channel: AnimationChannel; - keyframe: AnimationChannel["keys"][number]; -} - -function getBindingKeyframeMatches({ - animations, - binding, -}: { - animations: ElementAnimations; - binding: AnimationBindingInstance; -}): BindingKeyframeMatch[] { - return binding.components.flatMap((component, componentIndex) => { - const channel = animations.channels[component.channelId]; - if (!channel || channel.keys.length === 0) { - return []; - } - - return channel.keys.map((keyframe) => ({ - componentIndex, - channel, - keyframe, - })); - }); -} - -function getUniqueBindingKeyframeMatches({ - animations, - binding, -}: { - animations: ElementAnimations; - binding: AnimationBindingInstance; -}): BindingKeyframeMatch[] { - const sortedMatches = getBindingKeyframeMatches({ - animations, - binding, - }).sort( - (leftMatch, rightMatch) => - leftMatch.keyframe.time - rightMatch.keyframe.time || - leftMatch.componentIndex - rightMatch.componentIndex, - ); - const uniqueMatches: BindingKeyframeMatch[] = []; - - for (const match of sortedMatches) { - const previousMatch = uniqueMatches[uniqueMatches.length - 1]; - if ( - !previousMatch || - previousMatch.keyframe.time !== match.keyframe.time - ) { - uniqueMatches.push(match); - continue; - } - - if ( - previousMatch.componentIndex !== 0 && - match.componentIndex === 0 - ) { - uniqueMatches[uniqueMatches.length - 1] = match; - } - } - - return uniqueMatches; -} - -function getPreferredBindingKeyframeMatch({ - matches, -}: { - matches: BindingKeyframeMatch[]; -}): BindingKeyframeMatch | null { - return ( - matches.find((match) => match.componentIndex === 0) ?? - matches[0] ?? - null - ); -} - -function getComposedBindingValueAtTime({ - animations, - binding, - time, -}: { - animations: ElementAnimations; - binding: AnimationBindingInstance; - time: number; -}) { - const componentValues = Object.fromEntries( - binding.components.map((component) => { - const channel = animations.channels[component.channelId]; - return [ - component.key, - getChannelValueAtTime({ - channel, - time, - fallbackValue: getBindingFallbackValue({ channel }), - }), - ]; - }), - ) as Record; - - return composeAnimationValue({ - binding, - componentValues, - }); -} - -function getKeyframeInterpolation({ - channel, - keyframe, -}: { - channel: AnimationChannel; - keyframe: AnimationChannel["keys"][number]; -}) { - return channel.kind === "scalar" && "segmentToNext" in keyframe - ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) - : "hold"; -} - -function toElementKeyframe({ - animations, - binding, - propertyPath, - keyframeMatch, -}: { - animations: ElementAnimations; - binding: AnimationBindingInstance; - propertyPath: AnimationPath; - keyframeMatch: BindingKeyframeMatch; -}): ElementKeyframe | null { - const value = getComposedBindingValueAtTime({ - animations, - binding, - time: keyframeMatch.keyframe.time, - }); - if (value === null) { - return null; - } - - return { - propertyPath, - id: keyframeMatch.keyframe.id, - time: keyframeMatch.keyframe.time, - value, - interpolation: getKeyframeInterpolation({ - channel: keyframeMatch.channel, - keyframe: keyframeMatch.keyframe, - }), - }; -} - -export function getElementKeyframes({ - animations, -}: { - animations: ElementAnimations | undefined; -}): ElementKeyframe[] { - if (!animations) { - return []; - } - - return Object.entries(animations.bindings).flatMap( - ([propertyPath, binding]) => { - if (!binding || !isAnimationPath(propertyPath)) { - return []; - } - - return getUniqueBindingKeyframeMatches({ - animations, - binding, - }).flatMap((keyframeMatch) => { - const keyframe = toElementKeyframe({ - animations, - binding, - propertyPath, - keyframeMatch, - }); - if (!keyframe) { - return []; - } - - return [keyframe]; - }); - }, - ); -} - -export function hasKeyframesForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): boolean { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return false; - } - - return binding.components.some((component) => - Boolean(animations?.channels[component.channelId]?.keys.length), - ); -} - -export function getKeyframeAtTime({ - animations, - propertyPath, - time, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - time: number; -}): ElementKeyframe | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return null; - } - - const keyframeMatch = getPreferredBindingKeyframeMatch({ - matches: getBindingKeyframeMatches({ - animations, - binding, - }).filter(({ keyframe }) => keyframe.time === time), - }); - if (!keyframeMatch) { - return null; - } - - return toElementKeyframe({ - animations, - binding, - propertyPath, - keyframeMatch, - }); -} - -export function getKeyframeById({ - animations, - propertyPath, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - keyframeId: string; -}): ElementKeyframe | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return null; - } - - const keyframeMatch = getPreferredBindingKeyframeMatch({ - matches: getBindingKeyframeMatches({ - animations, - binding, - }).filter(({ keyframe }) => keyframe.id === keyframeId), - }); - if (!keyframeMatch) { - return null; - } - - return toElementKeyframe({ - animations, - binding, - propertyPath, - keyframeMatch, - }); -} diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts deleted file mode 100644 index a5efb6ee..00000000 --- a/apps/web/src/lib/animation/keyframes.ts +++ /dev/null @@ -1,1338 +0,0 @@ -import type { - AnimationBindingInstance, - AnimationBindingKind, - AnimationChannel, - AnimationInterpolation, - AnimationPath, - AnimationPropertyPath, - AnimationValue, - DiscreteAnimationChannel, - DiscreteAnimationKey, - ElementAnimations, - ScalarAnimationChannel, - ScalarAnimationKey, - ScalarCurveKeyframePatch, - ScalarSegmentType, -} from "@/lib/animation/types"; -import { generateUUID } from "@/utils/id"; -import { - cloneAnimationBinding, - createAnimationBinding, - decomposeAnimationValue, -} from "./binding-values"; -import { - getBezierPoint, - getDefaultLeftHandle, - getDefaultRightHandle, - solveBezierProgressForTime, -} from "./bezier"; -import { - getChannelValueAtTime, - getScalarSegmentInterpolation, - normalizeChannel, -} from "./interpolation"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, -} from "./property-registry"; - -function isNearlySameTime({ - leftTime, - rightTime, -}: { - leftTime: number; - rightTime: number; -}): boolean { - return leftTime === rightTime; -} - -function hasChannelKeys({ - channel, -}: { - channel: AnimationChannel | undefined; -}): boolean { - return Boolean(channel && channel.keys.length > 0); -} - -function toAnimation({ - animations, -}: { - animations: ElementAnimations; -}): ElementAnimations | undefined { - const nextBindings = Object.fromEntries( - Object.entries(animations.bindings).filter(([, binding]) => binding), - ); - const nextChannels = Object.fromEntries( - Object.entries(animations.channels).filter(([, channel]) => - hasChannelKeys({ channel }), - ), - ); - if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) { - return undefined; - } - - return { - bindings: nextBindings, - channels: nextChannels, - }; -} - -function cloneAnimationsState({ - animations, -}: { - animations: ElementAnimations | undefined; -}): ElementAnimations { - return { - bindings: { ...(animations?.bindings ?? {}) }, - channels: { ...(animations?.channels ?? {}) }, - }; -} - -function getBindingChannelKind({ - kind, -}: { - kind: AnimationBindingKind; -}): AnimationChannel["kind"] { - return kind === "discrete" ? "discrete" : "scalar"; -} - -function getPrimaryComponent({ - binding, -}: { - binding: AnimationBindingInstance; -}) { - return binding.components[0] ?? null; -} - -function getPrimaryChannelId({ - binding, -}: { - binding: AnimationBindingInstance; -}) { - return getPrimaryComponent({ binding })?.channelId ?? null; -} - -function getScalarSegmentType({ - interpolation, -}: { - interpolation: AnimationInterpolation; -}): ScalarSegmentType { - if (interpolation === "hold") { - return "step"; - } - return interpolation === "bezier" ? "bezier" : "linear"; -} - -function getInterpolationForBinding({ - kind, - interpolation, -}: { - kind: AnimationBindingKind; - interpolation: AnimationInterpolation | undefined; -}): AnimationInterpolation { - if (kind === "discrete") { - return "hold"; - } - - if ( - interpolation === "linear" || - interpolation === "hold" || - interpolation === "bezier" - ) { - return interpolation; - } - - return "linear"; -} - -function createEmptyChannelForBindingKind({ - kind, -}: { - kind: AnimationBindingKind; -}): AnimationChannel { - if (kind === "discrete") { - return { - kind: "discrete", - keys: [], - } satisfies DiscreteAnimationChannel; - } - - return { - kind: "scalar", - keys: [], - } satisfies ScalarAnimationChannel; -} - -function createScalarKey({ - id, - time, - value, - interpolation, - previousKey, -}: { - id: string; - time: number; - value: number; - interpolation?: AnimationInterpolation; - previousKey?: ScalarAnimationKey; -}): ScalarAnimationKey { - return { - id, - time, - value, - leftHandle: previousKey?.leftHandle, - rightHandle: previousKey?.rightHandle, - segmentToNext: - previousKey?.segmentToNext ?? - getScalarSegmentType({ interpolation: interpolation ?? "linear" }), - tangentMode: previousKey?.tangentMode ?? "flat", - }; -} - -function createDiscreteKey({ - id, - time, - value, -}: { - id: string; - time: number; - value: string | boolean; -}): DiscreteAnimationKey { - return { - id, - time, - value, - }; -} - -function getBinding({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): AnimationBindingInstance | undefined { - return animations?.bindings[propertyPath]; -} - -function getChannelById({ - animations, - channelId, -}: { - animations: ElementAnimations | undefined; - channelId: string; -}): AnimationChannel | undefined { - return animations?.channels[channelId]; -} - -function getBindingComponent({ - binding, - componentKey, -}: { - binding: AnimationBindingInstance; - componentKey: string; -}) { - return binding.components.find((component) => component.key === componentKey) ?? null; -} - -function getTargetKeyMetadata({ - channel, - time, - keyframeId, -}: { - channel: AnimationChannel | undefined; - time: number; - keyframeId?: string; -}) { - const normalizedChannel = - channel != null ? normalizeChannel({ channel }) : undefined; - const keys = normalizedChannel?.keys ?? []; - if (keyframeId) { - const keyById = keys.find((key) => key.id === keyframeId); - if (keyById) { - return { - id: keyById.id, - time, - }; - } - } - - const keyAtTime = keys.find((key) => - isNearlySameTime({ leftTime: key.time, rightTime: time }), - ); - if (keyAtTime) { - return { - id: keyAtTime.id, - time: keyAtTime.time, - }; - } - - return { - id: keyframeId ?? generateUUID(), - time, - }; -} - -function upsertDiscreteChannelKey({ - channel, - time, - value, - keyframeId, -}: { - channel: DiscreteAnimationChannel | undefined; - time: number; - value: string | boolean; - keyframeId?: string; -}): DiscreteAnimationChannel { - const normalizedChannel = normalizeChannel({ - channel: channel ?? { kind: "discrete", keys: [] }, - }); - const keys = [...normalizedChannel.keys]; - if (keyframeId) { - const existingIndex = keys.findIndex((key) => key.id === keyframeId); - if (existingIndex >= 0) { - keys[existingIndex] = createDiscreteKey({ - id: keys[existingIndex].id, - time, - value, - }); - return normalizeChannel({ - channel: { kind: "discrete", keys }, - }); - } - } - - const existingAtTimeIndex = keys.findIndex((key) => - isNearlySameTime({ leftTime: key.time, rightTime: time }), - ); - if (existingAtTimeIndex >= 0) { - keys[existingAtTimeIndex] = createDiscreteKey({ - id: keys[existingAtTimeIndex].id, - time: keys[existingAtTimeIndex].time, - value, - }); - return normalizeChannel({ - channel: { kind: "discrete", keys }, - }); - } - - keys.push( - createDiscreteKey({ - id: keyframeId ?? generateUUID(), - time, - value, - }), - ); - return normalizeChannel({ - channel: { kind: "discrete", keys }, - }); -} - -function upsertScalarChannelKey({ - channel, - time, - value, - interpolation, - defaultInterpolation, - keyframeId, -}: { - channel: ScalarAnimationChannel | undefined; - time: number; - value: number; - interpolation?: AnimationInterpolation; - defaultInterpolation?: AnimationInterpolation; - keyframeId?: string; -}): ScalarAnimationChannel { - const normalizedChannel = normalizeChannel({ - channel: channel ?? { kind: "scalar", keys: [] }, - }); - const keys = [...normalizedChannel.keys]; - if (keyframeId) { - const existingIndex = keys.findIndex((key) => key.id === keyframeId); - if (existingIndex >= 0) { - keys[existingIndex] = createScalarKey({ - id: keys[existingIndex].id, - time, - value, - interpolation, - previousKey: - interpolation != null - ? { - ...keys[existingIndex], - segmentToNext: getScalarSegmentType({ interpolation }), - } - : keys[existingIndex], - }); - return normalizeChannel({ - channel: { - kind: "scalar", - keys, - extrapolation: normalizedChannel.extrapolation, - }, - }); - } - } - - const existingAtTimeIndex = keys.findIndex((key) => - isNearlySameTime({ leftTime: key.time, rightTime: time }), - ); - if (existingAtTimeIndex >= 0) { - keys[existingAtTimeIndex] = createScalarKey({ - id: keys[existingAtTimeIndex].id, - time: keys[existingAtTimeIndex].time, - value, - interpolation, - previousKey: - interpolation != null - ? { - ...keys[existingAtTimeIndex], - segmentToNext: getScalarSegmentType({ interpolation }), - } - : keys[existingAtTimeIndex], - }); - return normalizeChannel({ - channel: { - kind: "scalar", - keys, - extrapolation: normalizedChannel.extrapolation, - }, - }); - } - - keys.push( - createScalarKey({ - id: keyframeId ?? generateUUID(), - time, - value, - interpolation: interpolation ?? defaultInterpolation, - }), - ); - return normalizeChannel({ - channel: { - kind: "scalar", - keys, - extrapolation: normalizedChannel.extrapolation, - }, - }); -} - -export function getChannel({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): AnimationChannel | undefined { - const binding = getBinding({ animations, propertyPath }); - const primaryChannelId = - binding != null ? getPrimaryChannelId({ binding }) : null; - return primaryChannelId ? animations?.channels[primaryChannelId] : undefined; -} - -export function upsertPathKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, - kind, - defaultInterpolation, - coerceValue, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; -}): ElementAnimations | undefined { - const coercedValue = coerceValue({ value }); - if (coercedValue === null) { - return animations; - } - - const nextAnimations = cloneAnimationsState({ animations }); - const existingBinding = getBinding({ - animations, - propertyPath, - }); - const binding = - existingBinding && existingBinding.kind === kind - ? cloneAnimationBinding({ binding: existingBinding }) - : createAnimationBinding({ path: propertyPath, kind }); - const primaryChannel = getChannel({ - animations, - propertyPath, - }); - const targetKey = getTargetKeyMetadata({ - channel: primaryChannel, - time, - keyframeId, - }); - const componentValues = decomposeAnimationValue({ - kind, - value: coercedValue, - }); - if (!componentValues) { - return animations; - } - - const explicitInterpolation = - interpolation != null - ? getInterpolationForBinding({ kind, interpolation }) - : undefined; - const validatedDefaultInterpolation = getInterpolationForBinding({ - kind, - interpolation: defaultInterpolation, - }); - nextAnimations.bindings[propertyPath] = binding; - for (const component of binding.components) { - const nextValue = componentValues[component.key]; - if (nextValue == null) { - continue; - } - - const currentChannel = getChannelById({ - animations, - channelId: component.channelId, - }); - const targetChannel = - currentChannel?.kind === getBindingChannelKind({ kind }) - ? currentChannel - : createEmptyChannelForBindingKind({ kind }); - nextAnimations.channels[component.channelId] = - targetChannel.kind === "discrete" - ? upsertDiscreteChannelKey({ - channel: targetChannel, - time: targetKey.time, - value: nextValue as string | boolean, - keyframeId: targetKey.id, - }) - : upsertScalarChannelKey({ - channel: targetChannel, - time: targetKey.time, - value: nextValue as number, - interpolation: explicitInterpolation, - defaultInterpolation: validatedDefaultInterpolation, - keyframeId: targetKey.id, - }); - } - - return toAnimation({ - animations: nextAnimations, - }); -} - -export function upsertElementKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath, - value, - }); - if (coercedValue === null) { - return animations; - } - - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return upsertPathKeyframe({ - animations, - propertyPath, - time, - value: coercedValue, - interpolation, - keyframeId, - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - coerceValue: ({ value: nextValue }) => - coerceAnimationValueForProperty({ - propertyPath, - value: nextValue, - }), - }); -} - -export function upsertKeyframe({ - channel, - time, - value, - interpolation, - keyframeId, -}: { - channel: AnimationChannel | undefined; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): AnimationChannel | undefined { - if (!channel) { - return undefined; - } - - if (channel.kind === "discrete") { - if (typeof value !== "string" && typeof value !== "boolean") { - return channel; - } - - return upsertDiscreteChannelKey({ - channel, - time, - value, - keyframeId, - }); - } - - if (typeof value !== "number") { - return channel; - } - - return upsertScalarChannelKey({ - channel, - time, - value, - interpolation, - keyframeId, - }); -} - -export function removeKeyframe({ - channel, - keyframeId, -}: { - channel: AnimationChannel | undefined; - keyframeId: string; -}): AnimationChannel | undefined { - if (!channel) { - return undefined; - } - - const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); - if (nextKeys.length === 0) { - return undefined; - } - - return normalizeChannel({ - channel: { - ...channel, - keys: nextKeys, - } as AnimationChannel, - }); -} - -export function retimeKeyframe({ - channel, - keyframeId, - time, -}: { - channel: AnimationChannel | undefined; - keyframeId: string; - time: number; -}): AnimationChannel | undefined { - if (!channel) { - return undefined; - } - - const keyframeByIdIndex = channel.keys.findIndex( - (keyframe) => keyframe.id === keyframeId, - ); - if (keyframeByIdIndex < 0) { - return channel; - } - - const nextKeys = [...channel.keys]; - nextKeys[keyframeByIdIndex] = { - ...nextKeys[keyframeByIdIndex], - time, - }; - - return normalizeChannel({ - channel: { - ...channel, - keys: nextKeys, - } as AnimationChannel, - }); -} - -export function setChannel({ - animations, - propertyPath, - channel, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - channel: AnimationChannel | undefined; -}): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - if (binding.components.length !== 1) { - throw new Error( - `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, - ); - } - - const primaryComponent = getPrimaryComponent({ binding }); - if (!primaryComponent) { - return animations; - } - - return setBindingComponentChannel({ - animations, - propertyPath, - componentKey: primaryComponent.key, - channel, - }); -} - -export function setBindingComponentChannel({ - animations, - propertyPath, - componentKey, - channel, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - componentKey: string; - channel: AnimationChannel | undefined; -}): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const component = getBindingComponent({ - binding, - componentKey, - }); - if (!component) { - return animations; - } - - const nextAnimations = cloneAnimationsState({ animations }); - if (!channel || !hasChannelKeys({ channel })) { - delete nextAnimations.channels[component.channelId]; - const hasRemainingKeys = binding.components.some((candidate) => - hasChannelKeys({ - channel: nextAnimations.channels[candidate.channelId], - }), - ); - if (!hasRemainingKeys) { - delete nextAnimations.bindings[propertyPath]; - } - return toAnimation({ - animations: nextAnimations, - }); - } - - nextAnimations.channels[component.channelId] = normalizeChannel({ - channel, - }); - return toAnimation({ - animations: nextAnimations, - }); -} - -export function updateScalarKeyframeCurve({ - animations, - propertyPath, - componentKey, - keyframeId, - patch, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - componentKey: string; - keyframeId: string; - patch: ScalarCurveKeyframePatch; -}): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const component = getBindingComponent({ - binding, - componentKey, - }); - if (!component) { - return animations; - } - - const channel = getChannelById({ - animations, - channelId: component.channelId, - }); - if (channel?.kind !== "scalar") { - return animations; - } - - const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId); - if (keyframeIndex < 0) { - return animations; - } - - const nextKeys = [...channel.keys]; - const currentKey = nextKeys[keyframeIndex]; - nextKeys[keyframeIndex] = { - ...currentKey, - leftHandle: - patch.leftHandle === undefined - ? currentKey.leftHandle - : patch.leftHandle ?? undefined, - rightHandle: - patch.rightHandle === undefined - ? currentKey.rightHandle - : patch.rightHandle ?? undefined, - segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext, - tangentMode: patch.tangentMode ?? currentKey.tangentMode, - }; - - return setBindingComponentChannel({ - animations, - propertyPath, - componentKey, - channel: { - kind: "scalar", - keys: nextKeys, - extrapolation: channel.extrapolation, - }, - }); -} - -export function cloneAnimations({ - animations, - shouldRegenerateKeyframeIds = false, -}: { - animations: ElementAnimations | undefined; - shouldRegenerateKeyframeIds?: boolean; -}): ElementAnimations | undefined { - if (!animations) { - return undefined; - } - - const nextAnimations = cloneAnimationsState({ animations }); - nextAnimations.bindings = Object.fromEntries( - Object.entries(animations.bindings).map(([path, binding]) => [ - path, - binding ? cloneAnimationBinding({ binding }) : binding, - ]), - ); - nextAnimations.channels = {}; - - for (const binding of Object.values(nextAnimations.bindings)) { - if (!binding) { - continue; - } - - const primaryChannel = getChannelById({ - animations, - channelId: getPrimaryChannelId({ binding }) ?? "", - }); - const keyIdMap = new Map(); - if (primaryChannel) { - for (const key of primaryChannel.keys) { - keyIdMap.set( - key.id, - shouldRegenerateKeyframeIds ? generateUUID() : key.id, - ); - } - } - - for (const component of binding.components) { - const currentChannel = getChannelById({ - animations, - channelId: component.channelId, - }); - if (!currentChannel) { - continue; - } - - nextAnimations.channels[component.channelId] = normalizeChannel({ - channel: { - ...currentChannel, - keys: currentChannel.keys.map((key) => ({ - ...key, - id: keyIdMap.get(key.id) ?? key.id, - })), - } as AnimationChannel, - }); - } - } - - return toAnimation({ - animations: nextAnimations, - }); -} - -export function clampAnimationsToDuration({ - animations, - duration, -}: { - animations: ElementAnimations | undefined; - duration: number; -}): ElementAnimations | undefined { - if (!animations || duration <= 0) { - return undefined; - } - - return splitAnimationsAtTime({ - animations, - splitTime: duration, - shouldIncludeSplitBoundary: true, - }).leftAnimations; -} - -function lerpPoint({ - left, - right, - progress, -}: { - left: { x: number; y: number }; - right: { x: number; y: number }; - progress: number; -}) { - return { - x: left.x + (right.x - left.x) * progress, - y: left.y + (right.y - left.y) * progress, - }; -} - -function splitDiscreteChannelAtTime({ - channel, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, -}: { - channel: DiscreteAnimationChannel | undefined; - splitTime: number; - leftBoundaryId: string; - rightBoundaryId: string; - shouldIncludeSplitBoundary: boolean; -}) { - if (!channel || channel.keys.length === 0) { - return { - leftChannel: undefined, - rightChannel: undefined, - }; - } - - const normalizedChannel = normalizeChannel({ channel }); - let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); - let rightKeys = normalizedChannel.keys - .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); - - if (shouldIncludeSplitBoundary) { - const hasBoundaryOnLeft = leftKeys.some((key) => - isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), - ); - const hasBoundaryOnRight = rightKeys.some((key) => - isNearlySameTime({ leftTime: key.time, rightTime: 0 }), - ); - const boundaryValue = getChannelValueAtTime({ - channel: normalizedChannel, - time: splitTime, - fallbackValue: normalizedChannel.keys[0].value, - }); - if (!hasBoundaryOnLeft) { - leftKeys = [ - ...leftKeys, - createDiscreteKey({ - id: leftBoundaryId, - time: splitTime, - value: boundaryValue as string | boolean, - }), - ]; - } - if (!hasBoundaryOnRight) { - rightKeys = [ - createDiscreteKey({ - id: rightBoundaryId, - time: 0, - value: boundaryValue as string | boolean, - }), - ...rightKeys, - ]; - } - } - - return { - leftChannel: leftKeys.length - ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } }) - : undefined, - rightChannel: rightKeys.length - ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } }) - : undefined, - }; -} - -function splitScalarChannelAtTime({ - channel, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, -}: { - channel: ScalarAnimationChannel | undefined; - splitTime: number; - leftBoundaryId: string; - rightBoundaryId: string; - shouldIncludeSplitBoundary: boolean; -}) { - if (!channel || channel.keys.length === 0) { - return { - leftChannel: undefined, - rightChannel: undefined, - }; - } - - const normalizedChannel = normalizeChannel({ channel }); - let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); - let rightKeys = normalizedChannel.keys - .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); - - const hasBoundaryOnLeft = leftKeys.some((key) => - isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), - ); - const hasBoundaryOnRight = rightKeys.some((key) => - isNearlySameTime({ leftTime: key.time, rightTime: 0 }), - ); - if (!shouldIncludeSplitBoundary || (hasBoundaryOnLeft && hasBoundaryOnRight)) { - return { - leftChannel: leftKeys.length - ? normalizeChannel({ - channel: { - kind: "scalar", - keys: leftKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }) - : undefined, - rightChannel: rightKeys.length - ? normalizeChannel({ - channel: { - kind: "scalar", - keys: rightKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }) - : undefined, - }; - } - - for (let keyIndex = 0; keyIndex < normalizedChannel.keys.length - 1; keyIndex++) { - const leftKey = normalizedChannel.keys[keyIndex]; - const rightKey = normalizedChannel.keys[keyIndex + 1]; - if ( - !( - splitTime > leftKey.time && - splitTime < rightKey.time - ) - ) { - continue; - } - - const boundaryValue = getChannelValueAtTime({ - channel: normalizedChannel, - time: splitTime, - fallbackValue: leftKey.value, - }) as number; - - if (leftKey.segmentToNext === "bezier") { - const rightHandle = - leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); - const leftHandle = - rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); - const progress = solveBezierProgressForTime({ - time: splitTime, - leftKey, - rightKey, - }); - const p0 = { x: leftKey.time, y: leftKey.value }; - const p1 = { - x: leftKey.time + rightHandle.dt, - y: leftKey.value + rightHandle.dv, - }; - const p2 = { - x: rightKey.time + leftHandle.dt, - y: rightKey.value + leftHandle.dv, - }; - const p3 = { x: rightKey.time, y: rightKey.value }; - const q0 = lerpPoint({ left: p0, right: p1, progress }); - const q1 = lerpPoint({ left: p1, right: p2, progress }); - const q2 = lerpPoint({ left: p2, right: p3, progress }); - const r0 = lerpPoint({ left: q0, right: q1, progress }); - const r1 = lerpPoint({ left: q1, right: q2, progress }); - const splitPoint = lerpPoint({ left: r0, right: r1, progress }); - leftKeys = [ - ...normalizedChannel.keys.filter((key) => key.time < splitTime), - { - ...leftKey, - rightHandle: { - dt: q0.x - p0.x, - dv: q0.y - p0.y, - }, - }, - { - id: leftBoundaryId, - time: splitTime, - value: boundaryValue, - leftHandle: { - dt: r0.x - splitPoint.x, - dv: r0.y - splitPoint.y, - }, - segmentToNext: leftKey.segmentToNext, - tangentMode: leftKey.tangentMode, - }, - ]; - rightKeys = [ - { - id: rightBoundaryId, - time: 0, - value: boundaryValue, - rightHandle: { - dt: r1.x - splitPoint.x, - dv: r1.y - splitPoint.y, - }, - segmentToNext: "bezier", - tangentMode: leftKey.tangentMode, - }, - { - ...rightKey, - time: rightKey.time - splitTime, - leftHandle: { - dt: q2.x - p3.x, - dv: q2.y - p3.y, - }, - }, - ...normalizedChannel.keys - .filter((key) => key.time > rightKey.time) - .map((key) => ({ - ...key, - time: key.time - splitTime, - })), - ]; - } else { - leftKeys = [ - ...leftKeys, - createScalarKey({ - id: leftBoundaryId, - time: splitTime, - value: boundaryValue, - interpolation: "linear", - }), - ]; - rightKeys = [ - createScalarKey({ - id: rightBoundaryId, - time: 0, - value: boundaryValue, - interpolation: getScalarSegmentInterpolation({ - segment: leftKey.segmentToNext, - }), - }), - ...rightKeys, - ]; - } - - return { - leftChannel: normalizeChannel({ - channel: { - kind: "scalar", - keys: leftKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }), - rightChannel: normalizeChannel({ - channel: { - kind: "scalar", - keys: rightKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }), - }; - } - - return { - leftChannel: leftKeys.length - ? normalizeChannel({ - channel: { - kind: "scalar", - keys: leftKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }) - : undefined, - rightChannel: rightKeys.length - ? normalizeChannel({ - channel: { - kind: "scalar", - keys: rightKeys, - extrapolation: normalizedChannel.extrapolation, - }, - }) - : undefined, - }; -} - -export function splitAnimationsAtTime({ - animations, - splitTime, - shouldIncludeSplitBoundary = true, -}: { - animations: ElementAnimations | undefined; - splitTime: number; - shouldIncludeSplitBoundary?: boolean; -}): { - leftAnimations: ElementAnimations | undefined; - rightAnimations: ElementAnimations | undefined; -} { - if (!animations) { - return { leftAnimations: undefined, rightAnimations: undefined }; - } - - const leftAnimations = cloneAnimationsState({ animations: undefined }); - const rightAnimations = cloneAnimationsState({ animations: undefined }); - - for (const [propertyPath, binding] of Object.entries(animations.bindings)) { - if (!binding) { - continue; - } - - const leftBinding = cloneAnimationBinding({ binding }); - const rightBinding = cloneAnimationBinding({ binding }); - const leftBoundaryId = generateUUID(); - const rightBoundaryId = generateUUID(); - let hasLeftKeys = false; - let hasRightKeys = false; - - for (const component of binding.components) { - const channel = getChannelById({ - animations, - channelId: component.channelId, - }); - const splitResult = - channel?.kind === "discrete" - ? splitDiscreteChannelAtTime({ - channel, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, - }) - : splitScalarChannelAtTime({ - channel: channel as ScalarAnimationChannel | undefined, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, - }); - if (splitResult.leftChannel) { - leftAnimations.channels[component.channelId] = splitResult.leftChannel; - hasLeftKeys = true; - } - if (splitResult.rightChannel) { - rightAnimations.channels[component.channelId] = splitResult.rightChannel; - hasRightKeys = true; - } - } - - if (hasLeftKeys) { - leftAnimations.bindings[propertyPath] = leftBinding; - } - if (hasRightKeys) { - rightAnimations.bindings[propertyPath] = rightBinding; - } - } - - return { - leftAnimations: toAnimation({ animations: leftAnimations }), - rightAnimations: toAnimation({ animations: rightAnimations }), - }; -} - -export function removeElementKeyframe({ - animations, - propertyPath, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - keyframeId: string; -}): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const nextAnimations = cloneAnimationsState({ animations }); - for (const component of binding.components) { - nextAnimations.channels[component.channelId] = removeKeyframe({ - channel: nextAnimations.channels[component.channelId], - keyframeId, - }); - } - const hasRemainingKeys = binding.components.some((component) => - hasChannelKeys({ - channel: nextAnimations.channels[component.channelId], - }), - ); - if (!hasRemainingKeys) { - delete nextAnimations.bindings[propertyPath]; - for (const component of binding.components) { - delete nextAnimations.channels[component.channelId]; - } - } - return toAnimation({ - animations: nextAnimations, - }); -} - -export function retimeElementKeyframe({ - animations, - propertyPath, - keyframeId, - time, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - keyframeId: string; - time: number; -}): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const nextAnimations = cloneAnimationsState({ animations }); - for (const component of binding.components) { - nextAnimations.channels[component.channelId] = retimeKeyframe({ - channel: nextAnimations.channels[component.channelId], - keyframeId, - time, - }); - } - return toAnimation({ - animations: nextAnimations, - }); -} diff --git a/apps/web/src/lib/animation/property-groups.ts b/apps/web/src/lib/animation/property-groups.ts deleted file mode 100644 index 1e475dac..00000000 --- a/apps/web/src/lib/animation/property-groups.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { - AnimationPropertyGroup, - AnimationPropertyPath, - ElementAnimations, -} from "@/lib/animation/types"; -import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types"; -import { getKeyframeAtTime } from "./keyframe-query"; - -export interface GroupKeyframeRef { - propertyPath: AnimationPropertyPath; - keyframeId: string; -} - -export function getGroupKeyframesAtTime({ - animations, - group, - time, -}: { - animations: ElementAnimations | undefined; - group: AnimationPropertyGroup; - time: number; -}): GroupKeyframeRef[] { - return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => { - const keyframe = getKeyframeAtTime({ animations, propertyPath, time }); - return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : []; - }); -} - -export function hasGroupKeyframeAtTime({ - animations, - group, - time, -}: { - animations: ElementAnimations | undefined; - group: AnimationPropertyGroup; - time: number; -}): boolean { - return getGroupKeyframesAtTime({ animations, group, time }).length > 0; -} diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts deleted file mode 100644 index 56340fd8..00000000 --- a/apps/web/src/lib/animation/property-registry.ts +++ /dev/null @@ -1,390 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, -} from "@/lib/animation/types"; -import { parseColorToLinearRgba } from "./binding-values"; -import type { TimelineElement } from "@/lib/timeline"; -import { MIN_TRANSFORM_SCALE } from "@/lib/animation/transform"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/lib/text/background"; -import { - canElementHaveAudio, - isVisualElement, -} from "@/lib/timeline/element-utils"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { snapToStep } from "@/utils/math"; - -export interface NumericSpec { - min?: number; - max?: number; - step?: number; -} - -export interface AnimationPropertyDefinition { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial>; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceNumberValue({ - value, - numericRange, -}: { - value: AnimationValue; - numericRange?: NumericSpec; -}): number | null { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ value, numericRange }); -} - -function coerceColorValue({ - value, -}: { - value: AnimationValue; -}): string | null { - return typeof value === "string" && parseColorToLinearRgba({ color: value }) - ? value - : null; -} - -function createNumberPropertyDefinition({ - numericRange, - supportsElement, - getValue, - setValue, -}: { - numericRange?: NumericSpec; - supportsElement: AnimationPropertyDefinition["supportsElement"]; - getValue: AnimationPropertyDefinition["getValue"]; - setValue: AnimationPropertyDefinition["setValue"]; -}): AnimationPropertyDefinition { - return { - kind: "number", - defaultInterpolation: "linear", - numericRanges: numericRange ? { value: numericRange } : undefined, - supportsElement, - getValue, - coerceValue: ({ value }) => - coerceNumberValue({ - value, - numericRange, - }), - setValue, - }; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.positionX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.x : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, x: value as number }, - }, - } - : element, - }), - "transform.positionY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.y : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, y: value as number }, - }, - } - : element, - }), - "transform.scaleX": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }), - "transform.scaleY": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }), - "transform.rotate": createNumberPropertyDefinition({ - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }), - opacity: createNumberPropertyDefinition({ - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }), - volume: createNumberPropertyDefinition({ - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }), - color: { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }), - "background.paddingY": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }), - "background.offsetX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }), - "background.offsetY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }), - "background.cornerRadius": createNumberPropertyDefinition({ - numericRange: { - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - step: 1, - }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }), -}; - -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const definition = getAnimationPropertyDefinition({ propertyPath }); - const coercedValue = definition.coerceValue({ value }); - if (coercedValue === null || !definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.coerceValue({ value }); -} diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts deleted file mode 100644 index 42c1c73c..00000000 --- a/apps/web/src/lib/animation/resolve.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { - AnimationColorPropertyPath, - AnimationNumericPropertyPath, - AnimationPath, - AnimationPropertyPath, - AnimationValueForPath, - ElementAnimations, -} from "@/lib/animation/types"; -import type { Transform } from "@/lib/rendering"; -import { - type AnimationComponentValue, - composeAnimationValue, - decomposeAnimationValue, -} from "./binding-values"; -import { - getChannelValueAtTime, -} from "./interpolation"; - -export function getElementLocalTime({ - timelineTime, - elementStartTime, - elementDuration, -}: { - timelineTime: number; - elementStartTime: number; - elementDuration: number; -}): number { - const localTime = timelineTime - elementStartTime; - if (localTime <= 0) { - return 0; - } - - if (localTime >= elementDuration) { - return elementDuration; - } - - return localTime; -} - -export function resolveTransformAtTime({ - baseTransform, - animations, - localTime, -}: { - baseTransform: Transform; - animations: ElementAnimations | undefined; - localTime: number; -}): Transform { - const safeLocalTime = Math.max(0, localTime); - return { - position: { - x: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionX", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.x, - }), - y: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionY", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.y, - }), - }, - scaleX: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleX", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleX, - }), - scaleY: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleY", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleY, - }), - rotate: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.rotate", - localTime: safeLocalTime, - fallbackValue: baseTransform.rotate, - }), - }; -} - -export function resolveOpacityAtTime({ - baseOpacity, - animations, - localTime, -}: { - baseOpacity: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath: "opacity", - localTime: Math.max(0, localTime), - fallbackValue: baseOpacity, - }); -} - -export function resolveNumberAtTime({ - baseValue, - animations, - propertyPath, - localTime, -}: { - baseValue: number; - animations: ElementAnimations | undefined; - propertyPath: AnimationNumericPropertyPath; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseValue, - }); -} - -export function resolveColorAtTime({ - baseColor, - animations, - propertyPath, - localTime, -}: { - baseColor: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationColorPropertyPath; - localTime: number; -}): string { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseColor, - }); -} - -export function resolveAnimationPathValueAtTime({ - 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/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts deleted file mode 100644 index 64bb8709..00000000 --- a/apps/web/src/lib/animation/target-resolver.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPath, - AnimationValue, -} from "@/lib/animation/types"; -import { - parseEffectParamPath, - isEffectParamPath, -} from "@/lib/animation/effect-param-channel"; -import { - isGraphicParamPath, - parseGraphicParamPath, -} from "@/lib/animation/graphic-param-channel"; -import type { ParamDefinition } from "@/lib/params"; -import { effectsRegistry, registerDefaultEffects } from "@/lib/effects"; -import { getGraphicDefinition } from "@/lib/graphics"; -import type { TimelineElement } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { snapToStep } from "@/utils/math"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getElementBaseValueForProperty, - isAnimationPropertyPath, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; -import { parseColorToLinearRgba } from "./binding-values"; - -export interface AnimationPathDescriptor { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial>; - 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/lib/animation/transform.ts b/apps/web/src/lib/animation/transform.ts deleted file mode 100644 index ab932ea2..00000000 --- a/apps/web/src/lib/animation/transform.ts +++ /dev/null @@ -1 +0,0 @@ -export const MIN_TRANSFORM_SCALE = 0.01; diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts deleted file mode 100644 index 115b4dbf..00000000 --- a/apps/web/src/lib/animation/types.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { ParamValues } from "@/lib/params"; - -export const ANIMATION_PROPERTY_PATHS = [ - "transform.positionX", - "transform.positionY", - "transform.scaleX", - "transform.scaleY", - "transform.rotate", - "opacity", - "volume", - "color", - "background.color", - "background.paddingX", - "background.paddingY", - "background.offsetX", - "background.offsetY", - "background.cornerRadius", -] as const; - -export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; -export type GraphicParamPath = `params.${string}`; -export type EffectParamPath = `effects.${string}.params.${string}`; -export type AnimationPath = - | AnimationPropertyPath - | GraphicParamPath - | EffectParamPath; - -export const ANIMATION_PROPERTY_GROUPS = { - "transform.scale": ["transform.scaleX", "transform.scaleY"], -} as const satisfies Record>; - -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/lib/auth/client.ts b/apps/web/src/lib/auth/client.ts deleted file mode 100644 index 3bec5072..00000000 --- a/apps/web/src/lib/auth/client.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createAuthClient } from "better-auth/react"; -import { webEnv } from "@/lib/env/web"; - -export const { signIn, signUp, useSession } = createAuthClient({ - baseURL: webEnv.NEXT_PUBLIC_SITE_URL, -}); diff --git a/apps/web/src/lib/auth/server.ts b/apps/web/src/lib/auth/server.ts deleted file mode 100644 index 39fce38f..00000000 --- a/apps/web/src/lib/auth/server.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { betterAuth, type RateLimit } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { Redis } from "@upstash/redis"; -import { db } from "@/lib/db"; -import { webEnv } from "@/lib/env/web"; - -const redis = new Redis({ - url: webEnv.UPSTASH_REDIS_REST_URL, - token: webEnv.UPSTASH_REDIS_REST_TOKEN, -}); - -export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "pg", - usePlural: true, - }), - secret: webEnv.BETTER_AUTH_SECRET, - user: { - deleteUser: { - enabled: true, - }, - }, - emailAndPassword: { - enabled: true, - }, - rateLimit: { - storage: "secondary-storage", - customStorage: { - get: async (key) => { - const value = await redis.get(key); - return value as RateLimit | undefined; - }, - set: async (key, value) => { - await redis.set(key, value); - }, - }, - }, - baseURL: webEnv.NEXT_PUBLIC_SITE_URL, - appName: "OpenCut", - trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL], -}); - -export type Auth = typeof auth; diff --git a/apps/web/src/lib/background/blur.ts b/apps/web/src/lib/background/blur.ts deleted file mode 100644 index 3b111482..00000000 --- a/apps/web/src/lib/background/blur.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{ - label: string; - value: number; -}> = [ - { label: "Light", value: 100 }, - { label: "Medium", value: 200 }, - { label: "Heavy", value: 500 }, -] as const; - -export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; diff --git a/apps/web/src/lib/background/color.ts b/apps/web/src/lib/background/color.ts deleted file mode 100644 index 7b86faf5..00000000 --- a/apps/web/src/lib/background/color.ts +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_BACKGROUND_COLOR = "#000000"; diff --git a/apps/web/src/lib/blog/query.ts b/apps/web/src/lib/blog/query.ts deleted file mode 100644 index 91c2a514..00000000 --- a/apps/web/src/lib/blog/query.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { - MarbleAuthorList, - MarbleCategoryList, - Pagination, - MarblePost, - MarblePostList, - MarbleTagList, -} from "@/lib/blog/types"; -import { unified } from "unified"; -import rehypeParse from "rehype-parse"; -import rehypeStringify from "rehype-stringify"; -import rehypeSlug from "rehype-slug"; -import rehypeAutolinkHeadings from "rehype-autolink-headings"; -import rehypeSanitize from "rehype-sanitize"; - -const url = - process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com"; -const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46"; - -const EMPTY_PAGINATION: Pagination = { - limit: 0, - currpage: 1, - nextPage: null, - prevPage: null, - totalItems: 0, - totalPages: 0, -}; - -const EMPTY_POSTS: MarblePostList = { - posts: [], - pagination: EMPTY_PAGINATION, -}; - -const EMPTY_TAGS: MarbleTagList = { - tags: [], - pagination: EMPTY_PAGINATION, -}; - -const EMPTY_CATEGORIES: MarbleCategoryList = { - categories: [], - pagination: EMPTY_PAGINATION, -}; - -const EMPTY_AUTHORS: MarbleAuthorList = { - authors: [], - pagination: EMPTY_PAGINATION, -}; - -function isMarbleConfigured() { - return ![ - "", - "placeholder", - "build-placeholder", - "your_workspace_key_here", - ].includes(key); -} - -async function fetchFromMarble({ - endpoint, - fallback, -}: { - endpoint: string; - fallback: T; -}): Promise { - if (!isMarbleConfigured()) { - return fallback; - } - - try { - const response = await fetch(`${url}/${key}/${endpoint}`); - if (!response.ok) { - console.warn( - `Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`, - ); - return fallback; - } - return (await response.json()) as T; - } catch (error) { - console.error(`Error fetching ${endpoint}:`, error); - return fallback; - } -} - -export async function getPosts() { - return fetchFromMarble({ - endpoint: "posts", - fallback: EMPTY_POSTS, - }); -} - -export async function getTags() { - return fetchFromMarble({ - endpoint: "tags", - fallback: EMPTY_TAGS, - }); -} - -export async function getSinglePost({ slug }: { slug: string }) { - return fetchFromMarble({ - endpoint: `posts/${slug}`, - fallback: null, - }); -} - -export async function getCategories() { - return fetchFromMarble({ - endpoint: "categories", - fallback: EMPTY_CATEGORIES, - }); -} - -export async function getAuthors() { - return fetchFromMarble({ - endpoint: "authors", - fallback: EMPTY_AUTHORS, - }); -} - -export async function processHtmlContent({ - html, -}: { - html: string; -}): Promise { - const processor = unified() - .use(rehypeSanitize) - .use(rehypeParse, { fragment: true }) - .use(rehypeSlug) - .use(rehypeAutolinkHeadings, { behavior: "append" }) - .use(rehypeStringify); - - const file = await processor.process({ value: html, type: "html" }); - return String(file); -} diff --git a/apps/web/src/lib/blog/types.ts b/apps/web/src/lib/blog/types.ts deleted file mode 100644 index 7b2f450a..00000000 --- a/apps/web/src/lib/blog/types.ts +++ /dev/null @@ -1,92 +0,0 @@ -export type Post = { - id: string; - slug: string; - title: string; - content: string; - description: string; - coverImage: string; - publishedAt: Date; - updatedAt: Date; - authors: { - id: string; - name: string; - image: string; - }[]; - category: { - id: string; - slug: string; - name: string; - }; - tags: { - id: string; - slug: string; - name: string; - }[]; - attribution: { - author: string; - url: string; - } | null; -}; - -export type Pagination = { - limit: number; - currpage: number; - nextPage: number | null; - prevPage: number | null; - totalItems: number; - totalPages: number; -}; - -export type MarblePostList = { - posts: Post[]; - pagination: Pagination; -}; - -export type MarblePost = { - post: Post; -}; - -export type Tag = { - id: string; - name: string; - slug: string; -}; - -export type MarbleTag = { - tag: Tag; -}; - -export type MarbleTagList = { - tags: Tag[]; - pagination: Pagination; -}; - -export type Category = { - id: string; - name: string; - slug: string; -}; - -export type MarbleCategory = { - category: Category; -}; - -export type MarbleCategoryList = { - categories: Category[]; - pagination: Pagination; -}; - -export type Author = { - id: string; - name: string; - image: string; -}; - -export type MarbleAuthor = { - author: Author; -}; - -export type MarbleAuthorList = { - authors: Author[]; - pagination: Pagination; -}; diff --git a/apps/web/src/lib/cancel-interaction.ts b/apps/web/src/lib/cancel-interaction.ts deleted file mode 100644 index 297a2062..00000000 --- a/apps/web/src/lib/cancel-interaction.ts +++ /dev/null @@ -1,24 +0,0 @@ -type CancelFn = () => void; - -const cancellers = new Set(); - -export function registerCanceller({ fn }: { fn: CancelFn }): () => void { - cancellers.add(fn); - - return () => { - cancellers.delete(fn); - }; -} - -export function cancelInteraction(): boolean { - if (cancellers.size === 0) return false; - - const activeCancellers = Array.from(cancellers); - cancellers.clear(); - - for (const cancel of activeCancellers) { - cancel(); - } - - return true; -} diff --git a/apps/web/src/lib/canvas/sizes.ts b/apps/web/src/lib/canvas/sizes.ts deleted file mode 100644 index dce75bcb..00000000 --- a/apps/web/src/lib/canvas/sizes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { TCanvasSize } from "@/lib/project/types"; - -export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [ - { width: 1920, height: 1080 }, - { width: 1080, height: 1920 }, - { width: 1080, height: 1080 }, - { width: 1440, height: 1080 }, -]; - -export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 }; diff --git a/apps/web/src/lib/changelog/components/changelog-notification.tsx b/apps/web/src/lib/changelog/components/changelog-notification.tsx deleted file mode 100644 index 4433df30..00000000 --- a/apps/web/src/lib/changelog/components/changelog-notification.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import Link from "next/link"; -import { Cancel01Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { Button } from "@/components/ui/button"; -import { getSortedReleases } from "../utils"; -import type { Release } from "../utils"; - -const STORAGE_KEY = "last-seen-version"; - -export function ChangelogNotification() { - const [release, setRelease] = useState(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/lib/changelog/components/copy-markdown-button.tsx b/apps/web/src/lib/changelog/components/copy-markdown-button.tsx deleted file mode 100644 index bc7e94bf..00000000 --- a/apps/web/src/lib/changelog/components/copy-markdown-button.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { CheckIcon, ClipboardIcon } from "lucide-react"; -import { - getSectionTitle, - groupAndOrderChanges, - isSectionCollapsible, -} from "../utils"; -import type { Change } from "../utils"; -import { cn } from "@/utils/ui"; -import { Button } from "@/components/ui/button"; - -function buildMarkdown({ - description, - changes, -}: { - description?: string; - changes: Change[]; -}): string { - const lines: string[] = []; - - if (description) { - lines.push(description, ""); - } - - const { grouped, orderedTypes } = groupAndOrderChanges({ changes }); - - for (const type of orderedTypes) { - if (isSectionCollapsible({ type })) { - lines.push( - buildCollapsibleMarkdownSection({ - title: getSectionTitle({ type }), - changes: grouped[type], - }), - "", - ); - continue; - } - - lines.push(`## ${getSectionTitle({ type })}`); - for (const change of grouped[type]) { - lines.push(`- ${change.text}`); - } - lines.push(""); - } - - return lines.join("\n").trimEnd(); -} - -function buildCollapsibleMarkdownSection({ - title, - changes, -}: { - title: string; - changes: Change[]; -}): string { - const bulletLines = changes.map((change) => `- ${change.text}`).join("\n"); - - return `
\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/lib/changelog/components/release.tsx b/apps/web/src/lib/changelog/components/release.tsx deleted file mode 100644 index 475b0a6d..00000000 --- a/apps/web/src/lib/changelog/components/release.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import type { ReactNode } from "react"; -import Link from "next/link"; -import { cn } from "@/utils/ui"; -import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper"; -import { - getSectionTitle, - groupAndOrderChanges, - isSectionCollapsible, - type Change, - type Release, -} from "../utils"; -import { ArrowRightIcon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function ReleaseArticle({ - variant, - isLatest, - children, -}: { - variant: "list" | "detail"; - isLatest?: boolean; - children: ReactNode; -}) { - if (variant === "list") { - return ( -
-
-
-
-
{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/lib/changelog/entries/0.1.0.md b/apps/web/src/lib/changelog/entries/0.1.0.md deleted file mode 100644 index 33c5413c..00000000 --- a/apps/web/src/lib/changelog/entries/0.1.0.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -version: "0.1.0" -date: "2026-02-23" -published: true -title: "Editor foundation" -description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use." -summary: "Properties panel overhaul, 1,000+ fonts, blend modes, a new color picker, and direct manipulation in the preview." -changes: - - type: new - text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in." - - type: new - text: "The properties panel went from a flat list of basic text styles to organized sections: transform, blending, typography, spacing, and background controls. Text elements finally have position, scale, and rotation." - - type: new - text: "New color picker with eyedropper, opacity control, and multiple color formats." - - type: new - text: "Bookmarks now support notes, color labels, and optional duration to plan scenes more clearly." - - type: new - text: "Move, scale, and rotate elements directly in the preview panel." - - type: new - text: "Blend modes (Multiply, Screen, Overlay, etc). Only available on text elements for now." - - type: new - text: "Paste images, videos, or audio from your clipboard straight into the editor." - - type: new - text: "Hold Shift while moving or resizing to temporarily turn off snapping." - - type: improved - text: "Expanded font selection from 7 to over 1,000." - - type: improved - text: "Fonts load much faster." - - type: improved - text: "All asset panel tabs now share a consistent layout." - - type: improved - text: "Text rendering properly supports multiple lines, line height, and letter spacing." - - type: improved - text: "Better contrast in the dark theme." - - type: fixed - text: "Fixed undo/redo for drag-and-drop so dropped items are reliably undoable." ---- diff --git a/apps/web/src/lib/changelog/entries/0.2.0.md b/apps/web/src/lib/changelog/entries/0.2.0.md deleted file mode 100644 index 261b47d2..00000000 --- a/apps/web/src/lib/changelog/entries/0.2.0.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -version: "0.2.0" -date: "2026-03-01" -published: true -title: "Motion & effects" -description: "This release adds the foundation for motion and effects, alongside many improvements & fixes." -summary: "Keyframe animation, a new effects system with per-clip blur, and ripple editing mode." -changes: - - type: new - text: "Keyframe animation system for animating element properties over time." - - type: new - text: "Entirely new efects system. Effects can be applied per-clip or added as standalone elements on the timeline (with our first effect: Blur!)" - - type: new - text: "Added a changelog page to the site." - - type: new - text: "Ripple editing mode." - - type: fixed - text: "Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below." - - type: fixed - text: "Audio could flicker or cut out at the start of playback when certain elements were selected. The scheduler now recovers from timing slips without losing audio." - - type: fixed - text: "Dragging a clip onto the timeline and pressing Ctrl+Z now removes both the clip and the track it created in one step, instead of requiring two undos." - - type: fixed - text: "A few values in the properties panel were previously showing incorrect values. This has been fixed." - - type: improved - text: "New text elements no longer have a black background by default." - - type: improved - text: "Selection outline in the preview is now dashed." - - type: improved - text: "Clips sitting next to each other on the timeline now have a small gap between them so selected clips are always clearly visible." - - type: fixed - text: "Timeline trim handles now sit outside the clip instead of inside them, so the clip's visible area accurately represents where it starts and ends." - - type: fixed - text: "Clips being dragged on the timeline now render on top of other clips instead of underneath them." - - type: fixed - text: "Undoing a trim-from-start operation now correctly restores the clip to its original position and length, instead of stretching it to the right." - - type: fixed - text: "Resizing a clip on the timeline would deselect it when the mouse was released. The clip now stays selected after a resize." - - type: improved - text: "Press Escape to deselect the current element." - - type: fixed - text: "Resizing a clip could extend it past another clip on the same track, causing an overlap. The resize now stops at the next clip's edge." - - type: fixed - text: "Dragging a clip to a different track would deselect it on drop. The clip stays selected after moving." - - type: fixed - text: "Finishing a resize or drag with the mouse over empty track space would deselect the clip." ---- diff --git a/apps/web/src/lib/changelog/entries/0.3.0.md b/apps/web/src/lib/changelog/entries/0.3.0.md deleted file mode 100644 index 86c62cff..00000000 --- a/apps/web/src/lib/changelog/entries/0.3.0.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -version: "0.3.0" -date: "2026-04-15" -published: true -title: "Masks, animation & more" -description: "This release adds masks, a graph editor for keyframe curves, volume and speed controls, preview zoom, canvas backgrounds, stickers, and a lot more." -summary: "Masks, graph editor for curves, volume & speed controls, preview zoom, canvas backgrounds, and stickers." -changes: - - type: new - text: "Added a brand page with downloadable brand assets. Assets are also accessible directly from the header logo context menu." - - type: fixed - text: "Sponsor logos now correctly invert in dark mode." - - type: improved - text: "Added a changelog link to the footer." - - type: fixed - text: "Media asset cards in the grid view were larger than their thumbnails, leaving empty space around each item. Cards now size to their content." - - type: improved - text: "Playback performance is dramatically better. The editor used to slow to a crawl during playback, and audio would stutter when interacting with elements mid-play. Both are fixed." - - type: fixed - text: "Project thumbnails now generate correctly even when the timeline is empty. If you set a background color or gradient with no clips, the thumbnail reflects that instead of staying blank." - - type: fixed - text: "Clicking a selected element no longer keeps the rest of the selection." - - type: improved - text: "More vertical space between timeline tracks. Selection outlines on adjacent tracks no longer overlap each other." - - type: fixed - text: "Track labels on the left side of the timeline were misaligned with the tracks on the right." - - type: improved - text: "Holding Shift or Ctrl while drag-selecting in the timeline now adds to your existing selection instead of replacing it. Matches how Shift/Ctrl+click already worked." - - type: improved - text: "Timeline track rows now highlight when they contain a selected element, making it easier to see which tracks are active at a glance." - - type: fixed - text: "Clicking in the empty space below timeline tracks now seeks the playhead." - - type: fixed - text: "Timeline vertical scroll now works anywhere in the panel, not just over the track labels. The header no longer scrolls with the tracks." - - type: new - text: "Scale now has separate width and height controls. You can stretch or squash elements independently on each axis." - - type: improved - text: "System fonts (Arial, Helvetica, Times New Roman, and others) now appear in the font picker alongside Google Fonts." - - type: fixed - text: "MP4 exports with audio failed on Firefox. The export now completes successfully regardless of browser." - - type: improved - text: "Your projects can disappear if your disk runs low on space. OpenCut now asks the browser to protect them." - - type: improved - text: "The properties panel has been redesigned with a tabbed layout." - - type: new - text: "Volume control for audio and video elements." - - type: new - text: "Speed control for video and audio clips. Includes a maintain pitch option so audio doesn't chipmunk or deepen when you change the rate." - - type: improved - text: "Audio waveforms have gotten an upgrade. The old ones were inaccurate, prone to crashing, and not very useful. The new ones are RMS-based, properly scaled, and only render what's visible so they don't slow things down." - - type: improved - text: "The timeline now opens scrolled to the bottom, so your main video track is visible right away instead of being hidden above the fold." - - type: fixed - text: "Snap guides in the preview panel didn't show when moving a full-canvas element to the center of the frame." - - type: fixed - text: "The rotation handle could float above the editor UI when an element was near the top of the canvas. It now clips to the preview area." - - type: fixed - text: "Images with a blur effect at 0% intensity would disappear completely when a blurred background was also active. Now 0% blur correctly renders the image unchanged." - - type: fixed - text: "Section titles in the properties panel showed the wrong text color when the section wasn't collapsible. Non-collapsible titles now use the correct muted style." - - type: new - text: "Masks. Hide or reveal parts of any video or image layer. Choose from split, rectangle, ellipse, star, heart, diamond, and cinematic bars. Adjust position, size, rotation, feather, and stroke in the properties panel, or drag the handles in the preview." - - type: improved - text: "The settings panel has been redesigned with a cleaner layout and better organization." - - type: new - text: "Canvas backgrounds are back. Set a blur, solid color, or gradient as the canvas background from the new Background tab in the settings panel." - - type: fixed - text: "Blur looked pixelated at higher intensities instead of producing a real blur. Both the canvas background blur and the blur effect are now fixed." - - type: new - text: "Custom canvas size. Instead of being locked to a handful of presets, you can now enter any width and height you want." - - type: new - text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in." - - type: new - text: "Stickers panel now has content. Browse and add country flags and shapes." - - type: improved - text: "You can now select multiple assets in the assets panel. Useful for deleting several at once." - - type: fixed - text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed." - - type: new - text: "You can now import transcript files to generate captions instead of running auto-transcription." - - type: new - text: "Graph editor for keyframe curves. Shape the easing between keyframes by dragging bezier handles." - - type: fixed - text: "Text element handles no longer shift position as you type." - - type: new - text: "Multiple selected timeline clips move and resize together." - - type: fixed - text: "The rotation handle drifted further and further from the element as the canvas size got smaller. On a 100x100 project it was almost off-screen. The gap is now a fixed size regardless of canvas dimensions or zoom." - - type: new - text: "Position X and Y can now be animated independently." - - type: new - text: "Keyframes can be copied and pasted between elements." - - type: new - text: 'Timeline clips can expand to show a lane for each animated property. Right-click a clip -> "Expand keyframes" to see all properties in their own rows.' - - type: improved - text: "If your browser doesn't support GPU-accelerated rendering, the editor now shows a notice explaining why and suggests switching to another browser." - - type: technical - text: "Ripple editing now has a proper algorithmic foundation. The old inline approach was replaced with a diff-based system in `src/lib/ripple/`: track snapshots before and after an operation are compared as interval sets, vacated intervals are computed (accounting for elements moved to other tracks), and the result is a list of explicit `RippleAdjustment` values applied in a separate pass. Composable, testable, and independent of the command that triggered it." - - type: technical - text: "Time is now represented as `MediaTime`, an integer tick count at 120,000 ticks per second, defined in `rust/crates/time`. Previously, floating-point seconds were used throughout, which accumulated rounding errors and made frame alignment imprecise. 120,000 was chosen because it divides evenly by every standard frame rate denominator, including drop-frame rates: 23.976 maps to 5,005 ticks/frame, 29.97 to 4,004, 30 to 4,000. `FrameRate` is a rational `{numerator, denominator}` type rather than a float. All time arithmetic, frame snapping, and timecode formatting are implemented in Rust and exposed via WASM." - - type: technical - text: "Track ordering is now enforced at the type level. `SceneTracks` replaces a flat track array with three explicit fields: `overlay`, `main` (a singleton `VideoTrack`), and `audio`. The type makes it structurally impossible to mix track kinds or misplace a track." - - type: technical - text: "The WebGL renderer has been replaced with a Rust/wgpu compositor compiled to WASM. The `rust/` crate tree covers compositing (`rust/crates/compositor`), effects (`rust/crates/effects`), masks with JFA-based feathering (`rust/crates/masks`), GPU context (`rust/crates/gpu`), and time (`rust/crates/time`). WASM bindings live in `rust/wasm/` and are consumed from TypeScript through `src/lib/wasm/`. The old GLSL shaders and WebGL utilities are gone." - - type: technical - text: "`src/types/` and `src/constants/` have been dissolved. All types and constants now live alongside the code that owns them inside `src/lib/`. The `packages/env` and `packages/ui` internal packages were also folded into the web app, with icons at `src/components/icons/` and env at `src/lib/env/`." - - type: technical - text: "The web app can now deploy to Cloudflare Workers via OpenNext. `wrangler.jsonc` and `apps/web/open-next.config.ts` are included for anyone self-hosting." - - type: technical - text: "`apps/desktop/` is a new GPUI + Rust app. It is early: there is a `main.rs` and a working build setup, but no features yet." - - type: technical - text: "16 new storage migration steps were added (v9 through v25), covering the schema changes accumulated during this release cycle." - ---- diff --git a/apps/web/src/lib/changelog/entries/0.4.0.md b/apps/web/src/lib/changelog/entries/0.4.0.md deleted file mode 100644 index 141df133..00000000 --- a/apps/web/src/lib/changelog/entries/0.4.0.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -version: "0.3.0" -date: "2026-03-04" -published: false -title: "Title" -description: "Description" -changes: -- type: new - text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight." -- type: new - text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke." -- type: fixed - text: "Preview panning now keeps working while the Masks tab is open." -- type: new - text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position." -- type: improved - text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top." -- type: fixed - text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio." \ No newline at end of file diff --git a/apps/web/src/lib/changelog/utils.ts b/apps/web/src/lib/changelog/utils.ts deleted file mode 100644 index 682d376a..00000000 --- a/apps/web/src/lib/changelog/utils.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { allChangelogs } from "content-collections"; - -export type Change = { type: string; text: string }; -export type Release = (typeof allChangelogs)[number]; - -type ChangeSectionConfig = { - title: string; - order: number; - collapsible?: boolean; -}; - -const knownSectionConfigs: Record = { - 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/lib/clipboard/handlers/elements.ts b/apps/web/src/lib/clipboard/handlers/elements.ts deleted file mode 100644 index 1b58ac78..00000000 --- a/apps/web/src/lib/clipboard/handlers/elements.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { PasteCommand } from "@/lib/commands/timeline"; -import type { ClipboardHandler } from "../types"; - -export const ElementsClipboardHandler = { - type: "elements", - - canCopy({ selectedElements }) { - return selectedElements.length > 0; - }, - - copy({ editor, selectedElements }) { - if (selectedElements.length === 0) { - return null; - } - - const results = editor.timeline.getElementsWithTracks({ - elements: selectedElements, - }); - const items = results.map(({ track, element }) => { - const { id: _elementId, ...elementWithoutId } = element; - return { - trackId: track.id, - trackType: track.type, - element: elementWithoutId, - }; - }); - - if (items.length === 0) { - return null; - } - - return { - type: "elements", - items, - }; - }, - - paste(entry, context) { - if (entry.items.length === 0) { - return null; - } - - return new PasteCommand({ - time: context.time, - clipboardItems: entry.items, - }); - }, -} satisfies ClipboardHandler<"elements">; diff --git a/apps/web/src/lib/clipboard/handlers/index.ts b/apps/web/src/lib/clipboard/handlers/index.ts deleted file mode 100644 index 29d4d1d0..00000000 --- a/apps/web/src/lib/clipboard/handlers/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { - ClipboardEntry, - ClipboardEntryType, - ClipboardHandler, - ClipboardHandlerMap, - CopyContext, - PasteContext, -} from "../types"; -import { ElementsClipboardHandler } from "./elements"; -import { KeyframesClipboardHandler } from "./keyframes"; - -export const clipboardHandlers = { - elements: ElementsClipboardHandler, - keyframes: KeyframesClipboardHandler, -} satisfies ClipboardHandlerMap; - -export const clipboardCopyHandlers = [ - KeyframesClipboardHandler, - ElementsClipboardHandler, -] as const satisfies readonly ClipboardHandler[]; - -export function copyClipboardEntry({ - context, -}: { - context: CopyContext; -}): ClipboardEntry | null { - for (const handler of clipboardCopyHandlers) { - if (!handler.canCopy(context)) { - continue; - } - - return handler.copy(context); - } - - return null; -} - -export function buildPasteClipboardCommand({ - entry, - context, -}: { - entry: ClipboardEntry; - context: PasteContext; -}) { - const handler = clipboardHandlers[entry.type] as ClipboardHandler< - typeof entry.type - >; - return handler.paste(entry as never, context); -} diff --git a/apps/web/src/lib/clipboard/handlers/keyframes.ts b/apps/web/src/lib/clipboard/handlers/keyframes.ts deleted file mode 100644 index b746c179..00000000 --- a/apps/web/src/lib/clipboard/handlers/keyframes.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { getKeyframeById } from "@/lib/animation"; -import type { SelectedKeyframeRef } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; -import { PasteKeyframesCommand } from "@/lib/commands/timeline"; -import type { - ClipboardHandler, - KeyframeClipboardCurvePatch, - KeyframeClipboardItem, -} from "../types"; - -function resolveSingleSourceElement({ - selectedKeyframes, -}: { - selectedKeyframes: SelectedKeyframeRef[]; -}) { - const firstKeyframe = selectedKeyframes[0]; - if (!firstKeyframe) { - return null; - } - - const sourceElement = { - trackId: firstKeyframe.trackId, - elementId: firstKeyframe.elementId, - }; - const isSingleSource = selectedKeyframes.every( - (keyframe) => - keyframe.trackId === sourceElement.trackId && - keyframe.elementId === sourceElement.elementId, - ); - - return isSingleSource ? sourceElement : null; -} - -function getCurvePatches({ - element, - propertyPath, - keyframeId, -}: { - element: TimelineElement; - propertyPath: KeyframeClipboardItem["propertyPath"]; - keyframeId: string; -}): KeyframeClipboardCurvePatch[] { - const binding = element.animations?.bindings[propertyPath]; - if (!binding) { - return []; - } - - return binding.components.flatMap((component) => { - const channel = element.animations?.channels[component.channelId]; - if (channel?.kind !== "scalar") { - return []; - } - - const keyframe = channel.keys.find( - (candidate) => candidate.id === keyframeId, - ); - if (!keyframe) { - return []; - } - - return [ - { - componentKey: component.key, - patch: { - leftHandle: keyframe.leftHandle ?? null, - rightHandle: keyframe.rightHandle ?? null, - segmentToNext: keyframe.segmentToNext, - tangentMode: keyframe.tangentMode, - }, - }, - ]; - }); -} - -function buildClipboardItem({ - element, - propertyPath, - keyframeId, -}: { - element: TimelineElement; - propertyPath: KeyframeClipboardItem["propertyPath"]; - keyframeId: string; -}): (Omit & { time: number }) | null { - const keyframe = getKeyframeById({ - animations: element.animations, - propertyPath, - keyframeId, - }); - if (!keyframe) { - return null; - } - - return { - propertyPath, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - curvePatches: getCurvePatches({ - element, - propertyPath, - keyframeId, - }), - }; -} - -export const KeyframesClipboardHandler = { - type: "keyframes", - - canCopy({ selectedKeyframes }) { - return selectedKeyframes.length > 0; - }, - - copy({ editor, selectedKeyframes }) { - const sourceElement = resolveSingleSourceElement({ selectedKeyframes }); - if (!sourceElement) { - return null; - } - - const [result] = editor.timeline.getElementsWithTracks({ - elements: [sourceElement], - }); - if (!result) { - return null; - } - - const rawItems = selectedKeyframes.flatMap((keyframeRef) => { - const item = buildClipboardItem({ - element: result.element, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - }); - return item ? [item] : []; - }); - if (rawItems.length === 0) { - return null; - } - - const minTime = Math.min(...rawItems.map((item) => item.time)); - const items = rawItems - .map(({ time, ...item }) => ({ - ...item, - timeOffset: time - minTime, - })) - .sort( - (left, right) => - left.timeOffset - right.timeOffset || - left.propertyPath.localeCompare(right.propertyPath), - ); - - return { - type: "keyframes", - sourceElement, - items, - }; - }, - - paste(entry, { selectedElements, time }) { - const targetElement = selectedElements[0]; - if (!targetElement || entry.items.length === 0) { - return null; - } - - return new PasteKeyframesCommand({ - trackId: targetElement.trackId, - elementId: targetElement.elementId, - time, - clipboardItems: entry.items, - }); - }, -} satisfies ClipboardHandler<"keyframes">; diff --git a/apps/web/src/lib/clipboard/index.ts b/apps/web/src/lib/clipboard/index.ts deleted file mode 100644 index 76d5bddf..00000000 --- a/apps/web/src/lib/clipboard/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./types"; -export * from "./handlers"; diff --git a/apps/web/src/lib/clipboard/types.ts b/apps/web/src/lib/clipboard/types.ts deleted file mode 100644 index 41e107ef..00000000 --- a/apps/web/src/lib/clipboard/types.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { EditorCore } from "@/core"; -import type { - AnimationInterpolation, - AnimationPath, - AnimationValue, - ScalarCurveKeyframePatch, - SelectedKeyframeRef, -} from "@/lib/animation/types"; -import type { Command } from "@/lib/commands/base-command"; -import type { - CreateTimelineElement, - ElementRef, - TrackType, -} from "@/lib/timeline"; - -export interface ElementClipboardItem { - trackId: string; - trackType: TrackType; - element: CreateTimelineElement; -} - -export interface KeyframeClipboardCurvePatch { - componentKey: string; - patch: ScalarCurveKeyframePatch; -} - -export interface KeyframeClipboardItem { - propertyPath: AnimationPath; - timeOffset: number; - value: AnimationValue; - interpolation: AnimationInterpolation; - curvePatches: KeyframeClipboardCurvePatch[]; -} - -export interface ElementsClipboardEntry { - type: "elements"; - items: ElementClipboardItem[]; -} - -export interface KeyframesClipboardEntry { - type: "keyframes"; - sourceElement: ElementRef; - items: KeyframeClipboardItem[]; -} - -export interface ClipboardEntryByType { - elements: ElementsClipboardEntry; - keyframes: KeyframesClipboardEntry; -} - -export type ClipboardEntry = ClipboardEntryByType[keyof ClipboardEntryByType]; -export type ClipboardEntryType = keyof ClipboardEntryByType; - -export interface CopyContext { - editor: EditorCore; - selectedElements: ElementRef[]; - selectedKeyframes: SelectedKeyframeRef[]; -} - -export interface PasteContext { - editor: EditorCore; - selectedElements: ElementRef[]; - selectedKeyframes: SelectedKeyframeRef[]; - time: number; -} - -export interface ClipboardHandler { - 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/lib/commands/base-command.ts b/apps/web/src/lib/commands/base-command.ts deleted file mode 100644 index b244f2f3..00000000 --- a/apps/web/src/lib/commands/base-command.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { EditorSelectionPatch } from "@/lib/selection/editor-selection"; -import type { ElementRef } from "@/lib/timeline/types"; - -export interface CommandResult { - selection?: EditorSelectionPatch; -} - -export function createElementSelectionResult( - selectedElements: ElementRef[], -): CommandResult { - return { - selection: { - selectedElements, - selectedKeyframes: [], - keyframeSelectionAnchor: null, - selectedMaskPoints: null, - }, - }; -} - -export abstract class Command { - abstract execute(): CommandResult | undefined; - - undo(): void { - throw new Error("Undo not implemented for this command"); - } - - redo(): CommandResult | undefined { - return this.execute(); - } -} diff --git a/apps/web/src/lib/commands/batch-command.ts b/apps/web/src/lib/commands/batch-command.ts deleted file mode 100644 index c4ee7e0b..00000000 --- a/apps/web/src/lib/commands/batch-command.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Command, type CommandResult } from "./base-command"; - -export class BatchCommand extends Command { - constructor(private commands: Command[]) { - super(); - } - - execute(): CommandResult | undefined { - let latestSelectionResult: CommandResult | undefined; - - for (const command of this.commands) { - const result = command.execute(); - if (result?.selection !== undefined) { - latestSelectionResult = result; - } - } - - return latestSelectionResult; - } - - undo(): void { - for (const command of [...this.commands].reverse()) { - command.undo(); - } - } - - redo(): CommandResult | undefined { - let latestSelectionResult: CommandResult | undefined; - - for (const command of this.commands) { - const result = command.redo(); - if (result?.selection !== undefined) { - latestSelectionResult = result; - } - } - - return latestSelectionResult; - } -} diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts deleted file mode 100644 index 78927ca9..00000000 --- a/apps/web/src/lib/commands/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { Command } from "./base-command"; -export type { CommandResult } from "./base-command"; -export { BatchCommand } from "./batch-command"; - -export * from "./timeline"; -export * from "./media"; -export * from "./scene"; -export * from "./project"; diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts deleted file mode 100644 index 12e0906e..00000000 --- a/apps/web/src/lib/commands/media/add-media-asset.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { toast } from "sonner"; -import type { MediaAsset } from "@/lib/media/types"; -import { generateUUID } from "@/utils/id"; -import { storageService } from "@/services/storage/service"; -import type { FrameRate } from "opencut-wasm"; -import { hasMediaId } from "@/lib/timeline/element-utils"; -import { frameRatesEqual, getHighestImportedVideoFps } from "@/lib/fps/utils"; -import { UpdateProjectSettingsCommand } from "@/lib/commands/project"; - -export class AddMediaAssetCommand extends Command { - private assetId: string; - private savedAssets: MediaAsset[] | null = null; - private createdAsset: MediaAsset | null = null; - private previousProjectFps: FrameRate | null = null; - private appliedProjectFps: FrameRate | null = null; - - constructor( - private projectId: string, - private asset: Omit, - ) { - super(); - this.assetId = generateUUID(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedAssets = [...editor.media.getAssets()]; - - this.createdAsset = { - ...this.asset, - id: this.assetId, - }; - - editor.media.setAssets({ - assets: [...this.savedAssets, this.createdAsset], - }); - this.previousProjectFps = editor.project.getActiveOrNull()?.settings.fps ?? null; - this.appliedProjectFps = editor.project.ratchetFpsForImportedMedia({ - importedAssets: [this.createdAsset], - }); - - storageService - .saveMediaAsset({ - projectId: this.projectId, - mediaAsset: this.createdAsset, - }) - .catch((error) => { - console.error("Failed to save media item:", error); - - const currentAssets = editor.media.getAssets(); - editor.media.setAssets({ - assets: currentAssets.filter((asset) => asset.id !== this.assetId), - }); - - const currentTracks = editor.scenes.getActiveScene().tracks; - const orphanedElements: Array<{ trackId: string; elementId: string }> = - []; - - for (const track of [ - ...currentTracks.overlay, - currentTracks.main, - ...currentTracks.audio, - ]) { - for (const element of track.elements) { - if (hasMediaId(element) && element.mediaId === this.assetId) { - orphanedElements.push({ - trackId: track.id, - elementId: element.id, - }); - } - } - } - - if (orphanedElements.length > 0) { - editor.timeline.deleteElements({ elements: orphanedElements }); - } - - this.restoreProjectFpsAfterFailedSave({ editor }); - - if (storageService.isQuotaExceededError({ error })) { - toast.error("Not enough browser storage", { - description: error instanceof Error ? error.message : undefined, - }); - } - }); - - return undefined; - } - - undo(): void { - if (this.savedAssets) { - const editor = EditorCore.getInstance(); - editor.media.setAssets({ assets: this.savedAssets }); - - if (this.createdAsset) { - storageService - .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) - .catch((error) => { - console.error("Failed to delete media item on undo:", error); - }); - } - } - } - - getAssetId(): string { - return this.assetId; - } - - private restoreProjectFpsAfterFailedSave({ - editor, - }: { - editor: EditorCore; - }): void { - if (this.previousProjectFps === null || this.appliedProjectFps === null) return; - - const activeProject = editor.project.getActiveOrNull(); - if (!activeProject) return; - if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return; - - const highestRemainingVideoFps = getHighestImportedVideoFps({ - mediaAssets: editor.media.getAssets(), - }); - const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator; - if ( - highestRemainingVideoFps !== null && - highestRemainingVideoFps >= appliedFpsFloat - ) { - return; - } - - new UpdateProjectSettingsCommand({ fps: this.previousProjectFps }).execute(); - } -} diff --git a/apps/web/src/lib/commands/media/index.ts b/apps/web/src/lib/commands/media/index.ts deleted file mode 100644 index 466222f2..00000000 --- a/apps/web/src/lib/commands/media/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AddMediaAssetCommand } from "./add-media-asset"; -export { RemoveMediaAssetCommand } from "./remove-media-asset"; diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts deleted file mode 100644 index 85d187d9..00000000 --- a/apps/web/src/lib/commands/media/remove-media-asset.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { MediaAsset } from "@/lib/media/types"; -import { buildWaveformSourceKey } from "@/lib/media/waveform-summary"; -import { storageService } from "@/services/storage/service"; -import { videoCache } from "@/services/video-cache/service"; -import { waveformCache } from "@/services/waveform-cache/service"; -import { hasMediaId } from "@/lib/timeline/element-utils"; -import type { SceneTracks } from "@/lib/timeline"; - -export class RemoveMediaAssetCommand extends Command { - private savedAssets: MediaAsset[] | null = null; - private savedTracks: SceneTracks | null = null; - private removedAsset: MediaAsset | null = null; - - constructor( - private projectId: string, - private assetId: string, - ) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const assets = editor.media.getAssets(); - - this.savedAssets = [...assets]; - this.savedTracks = editor.scenes.getActiveScene().tracks; - - this.removedAsset = - assets.find((media) => media.id === this.assetId) ?? null; - - if (!this.removedAsset) { - console.error("Media asset not found:", this.assetId); - return; - } - - if (this.removedAsset.url) { - URL.revokeObjectURL(this.removedAsset.url); - } - if (this.removedAsset.thumbnailUrl) { - URL.revokeObjectURL(this.removedAsset.thumbnailUrl); - } - - videoCache.clearVideo({ mediaId: this.assetId }); - waveformCache.clearSource({ - sourceKey: buildWaveformSourceKey({ - kind: "media", - id: this.assetId, - }), - }); - - editor.media.setAssets({ - assets: assets.filter((media) => media.id !== this.assetId), - }); - - const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; - - for (const track of [ - ...this.savedTracks.overlay, - this.savedTracks.main, - ...this.savedTracks.audio, - ]) { - for (const element of track.elements) { - if (hasMediaId(element) && element.mediaId === this.assetId) { - elementsToRemove.push({ trackId: track.id, elementId: element.id }); - } - } - } - - if (elementsToRemove.length > 0) { - editor.timeline.deleteElements({ elements: elementsToRemove }); - } - - storageService - .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) - .catch((error) => { - console.error("Failed to delete media item:", error); - }); - } - - undo(): void { - const editor = EditorCore.getInstance(); - - if (this.savedAssets && this.removedAsset) { - const restoredAsset: MediaAsset = { - ...this.removedAsset, - url: URL.createObjectURL(this.removedAsset.file), - }; - - editor.media.setAssets({ - assets: this.savedAssets.map((a) => - a.id === this.assetId ? restoredAsset : a, - ), - }); - - storageService - .saveMediaAsset({ - projectId: this.projectId, - mediaAsset: restoredAsset, - }) - .catch((error) => { - console.error("Failed to restore media item on undo:", error); - }); - } - - if (this.savedTracks) { - editor.timeline.updateTracks(this.savedTracks); - } - } -} diff --git a/apps/web/src/lib/commands/preview-tracker.ts b/apps/web/src/lib/commands/preview-tracker.ts deleted file mode 100644 index 68c8dee3..00000000 --- a/apps/web/src/lib/commands/preview-tracker.ts +++ /dev/null @@ -1,23 +0,0 @@ -export class PreviewTracker { - private snapshot: T | null = null; - - begin({ state }: { state: T }): void { - if (this.snapshot === null) { - this.snapshot = structuredClone(state); - } - } - - isActive(): boolean { - return this.snapshot !== null; - } - - getSnapshot(): T | null { - return this.snapshot; - } - - end(): T | null { - const snapshot = this.snapshot; - this.snapshot = null; - return snapshot; - } -} diff --git a/apps/web/src/lib/commands/project/index.ts b/apps/web/src/lib/commands/project/index.ts deleted file mode 100644 index a9f26edc..00000000 --- a/apps/web/src/lib/commands/project/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./update-project-settings"; diff --git a/apps/web/src/lib/commands/project/update-project-settings.ts b/apps/web/src/lib/commands/project/update-project-settings.ts deleted file mode 100644 index 32258e12..00000000 --- a/apps/web/src/lib/commands/project/update-project-settings.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TProject, TProjectSettings } from "@/lib/project/types"; - -export class UpdateProjectSettingsCommand extends Command { - private savedSettings: TProjectSettings | null = null; - private savedUpdatedAt: Date | null = null; - - constructor(private updates: Partial) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - this.savedSettings = activeProject.settings; - this.savedUpdatedAt = activeProject.metadata.updatedAt; - - const updatedProject: TProject = { - ...activeProject, - settings: { ...activeProject.settings, ...this.updates }, - metadata: { ...activeProject.metadata, updatedAt: new Date() }, - }; - - editor.project.setActiveProject({ project: updatedProject }); - editor.save.markDirty(); - } - - undo(): void { - if (!this.savedSettings || !this.savedUpdatedAt) return; - const editor = EditorCore.getInstance(); - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const updatedProject: TProject = { - ...activeProject, - settings: this.savedSettings, - metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt }, - }; - - editor.project.setActiveProject({ project: updatedProject }); - editor.save.markDirty(); - } -} diff --git a/apps/web/src/lib/commands/scene/create-scene.ts b/apps/web/src/lib/commands/scene/create-scene.ts deleted file mode 100644 index 1cc72c0f..00000000 --- a/apps/web/src/lib/commands/scene/create-scene.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { buildDefaultScene } from "@/lib/scenes"; - -export class CreateSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private createdScene: TScene | null = null; - - constructor( - private name: string, - private isMain: boolean = false, - ) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedScenes = [...editor.scenes.getScenes()]; - - this.createdScene = buildDefaultScene({ - name: this.name, - isMain: this.isMain, - }); - - const updatedScenes = [...this.savedScenes, this.createdScene]; - editor.scenes.setScenes({ scenes: updatedScenes }); - return undefined; - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } - - getSceneId(): string { - return this.createdScene?.id ?? ""; - } -} diff --git a/apps/web/src/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts deleted file mode 100644 index ab269b12..00000000 --- a/apps/web/src/lib/commands/scene/delete-scene.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes"; - -export class DeleteSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private savedActiveSceneId: string | null = null; - private deletedScene: TScene | null = null; - - constructor(private sceneId: string) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const scenes = editor.scenes.getScenes(); - const activeScene = editor.scenes.getActiveScene(); - - this.savedScenes = [...scenes]; - this.savedActiveSceneId = activeScene?.id ?? null; - - this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null; - - if (!this.deletedScene) { - console.error("Scene not found:", this.sceneId); - return; - } - - const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene }); - if (!canDelete) { - console.error("Cannot delete scene:", reason); - return; - } - - const updatedScenes = scenes.filter((s) => s.id !== this.sceneId); - - const newActiveScene = getFallbackSceneAfterDelete({ - scenes: updatedScenes, - deletedSceneId: this.sceneId, - currentSceneId: activeScene?.id ?? null, - }); - - editor.scenes.setScenes({ - scenes: updatedScenes, - activeSceneId: newActiveScene?.id, - }); - } - - undo(): void { - if (this.savedScenes && this.deletedScene) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ - scenes: this.savedScenes, - activeSceneId: this.savedActiveSceneId ?? undefined, - }); - } - } -} diff --git a/apps/web/src/lib/commands/scene/index.ts b/apps/web/src/lib/commands/scene/index.ts deleted file mode 100644 index 2b843c99..00000000 --- a/apps/web/src/lib/commands/scene/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { CreateSceneCommand } from "./create-scene"; -export { DeleteSceneCommand } from "./delete-scene"; -export { RenameSceneCommand } from "./rename-scene"; -export { ToggleBookmarkCommand } from "./toggle-bookmark"; -export { RemoveBookmarkCommand } from "./remove-bookmark"; -export { UpdateBookmarkCommand } from "./update-bookmark"; -export { MoveBookmarkCommand } from "./move-bookmark"; diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts deleted file mode 100644 index 103caa74..00000000 --- a/apps/web/src/lib/commands/scene/move-bookmark.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class MoveBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - - constructor( - private fromTime: number, - private toTime: number, - ) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - const fromFrameTime = getFrameTime({ - time: this.fromTime, - fps: activeProject.settings.fps, - }); - const toFrameTime = getFrameTime({ - time: this.toTime, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = moveBookmarkInArray({ - bookmarks: activeScene.bookmarks, - fromTime: fromFrameTime, - toTime: toFrameTime, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts deleted file mode 100644 index 5eac920f..00000000 --- a/apps/web/src/lib/commands/scene/remove-bookmark.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { - getFrameTime, - removeBookmarkFromArray, -} from "@/lib/timeline/bookmarks"; - -export class RemoveBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - private frameTime: number = 0; - - constructor(private time: number) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - this.frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = removeBookmarkFromArray({ - bookmarks: activeScene.bookmarks, - frameTime: this.frameTime, - }); - - if (updatedBookmarks.length === activeScene.bookmarks.length) { - return; - } - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts deleted file mode 100644 index ccdc85d6..00000000 --- a/apps/web/src/lib/commands/scene/rename-scene.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; - -export class RenameSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private previousName: string | null = null; - - constructor( - private sceneId: string, - private newName: string, - ) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const scenes = editor.scenes.getScenes(); - - this.savedScenes = [...scenes]; - - const scene = scenes.find((s) => s.id === this.sceneId); - if (!scene) { - console.error("Scene not found:", this.sceneId); - return; - } - - this.previousName = scene.name; - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: this.sceneId, - updates: { name: this.newName, updatedAt: new Date() }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes && this.previousName !== null) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts deleted file mode 100644 index cb4fa655..00000000 --- a/apps/web/src/lib/commands/scene/toggle-bookmark.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class ToggleBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - private frameTime: number = 0; - - constructor(private time: number) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - this.frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = toggleBookmarkInArray({ - bookmarks: activeScene.bookmarks, - frameTime: this.frameTime, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts deleted file mode 100644 index 1d1c7283..00000000 --- a/apps/web/src/lib/commands/scene/update-bookmark.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { Bookmark, TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class UpdateBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - - constructor( - private time: number, - private updates: Partial>, - ) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - const frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = updateBookmarkInArray({ - bookmarks: activeScene.bookmarks, - frameTime, - updates: this.updates, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/clipboard/index.ts b/apps/web/src/lib/commands/timeline/clipboard/index.ts deleted file mode 100644 index e893b62b..00000000 --- a/apps/web/src/lib/commands/timeline/clipboard/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { PasteCommand } from "./paste"; -export { PasteKeyframesCommand } from "./paste-keyframes"; diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts deleted file mode 100644 index 210afbd3..00000000 --- a/apps/web/src/lib/commands/timeline/clipboard/paste-keyframes.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { EditorCore } from "@/core"; -import { - getKeyframeAtTime, - resolveAnimationTarget, - updateScalarKeyframeCurve, - upsertPathKeyframe, -} from "@/lib/animation"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { KeyframeClipboardItem } from "@/lib/clipboard"; -import type { SceneTracks, TimelineElement } from "@/lib/timeline"; -import { updateElementInSceneTracks } from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; - -function pasteKeyframesIntoElement({ - element, - time, - clipboardItems, -}: { - element: TimelineElement; - time: number; - clipboardItems: KeyframeClipboardItem[]; -}): TimelineElement { - let nextElement = element; - - for (const item of clipboardItems) { - const target = resolveAnimationTarget({ - element: nextElement, - path: item.propertyPath, - }); - if (!target) { - continue; - } - - const keyframeTime = Math.max( - 0, - Math.min(time + item.timeOffset, nextElement.duration), - ); - const nextAnimations = upsertPathKeyframe({ - animations: nextElement.animations, - propertyPath: item.propertyPath, - time: keyframeTime, - value: item.value, - interpolation: item.interpolation, - keyframeId: generateUUID(), - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, - coerceValue: target.coerceValue, - }); - const pastedKeyframe = getKeyframeAtTime({ - animations: nextAnimations, - propertyPath: item.propertyPath, - time: keyframeTime, - }); - - let patchedAnimations = nextAnimations; - if (pastedKeyframe) { - for (const curvePatch of item.curvePatches) { - const nextPatchedAnimations = updateScalarKeyframeCurve({ - animations: patchedAnimations, - propertyPath: item.propertyPath, - componentKey: curvePatch.componentKey, - keyframeId: pastedKeyframe.id, - patch: curvePatch.patch, - }); - patchedAnimations = nextPatchedAnimations ?? patchedAnimations; - } - } - - nextElement = { - ...nextElement, - animations: patchedAnimations, - }; - } - - return nextElement; -} - -export class PasteKeyframesCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly time: number; - private readonly clipboardItems: KeyframeClipboardItem[]; - - constructor({ - trackId, - elementId, - time, - clipboardItems, - }: { - trackId: string; - elementId: string; - time: number; - clipboardItems: KeyframeClipboardItem[]; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.time = time; - this.clipboardItems = clipboardItems; - } - - execute(): CommandResult | undefined { - if (this.clipboardItems.length === 0) { - return undefined; - } - - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => - pasteKeyframesIntoElement({ - element, - time: this.time, - clipboardItems: this.clipboardItems, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts deleted file mode 100644 index 0fc7948f..00000000 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { SceneTracks } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import type { TimelineTrack } from "@/lib/timeline"; - -function removeTrackElements({ - track, - elements, -}: { - track: TTrack; - elements: { trackId: string; elementId: string }[]; -}): TTrack { - const nextElements = track.elements.filter( - (element) => - !elements.some( - (target) => - target.trackId === track.id && target.elementId === element.id, - ), - ); - - return { ...track, elements: nextElements } as TTrack; -} - -export class DeleteElementsCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly elements: { trackId: string; elementId: string }[]; - - constructor({ - elements, - }: { - elements: { trackId: string; elementId: string }[]; - }) { - super(); - this.elements = elements; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks: SceneTracks = { - overlay: this.savedState.overlay.map((track) => - removeTrackElements({ track, elements: this.elements }), - ), - main: removeTrackElements({ - track: this.savedState.main, - elements: this.elements, - }), - audio: this.savedState.audio.map((track) => - removeTrackElements({ track, elements: this.elements }), - ), - }; - - editor.timeline.updateTracks(updatedTracks); - - return { - selection: { - selectedElements: [], - selectedKeyframes: [], - keyframeSelectionAnchor: null, - selectedMaskPoints: null, - }, - }; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts deleted file mode 100644 index 902ca341..00000000 --- a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; -import type { SceneTracks, VisualElement } from "@/lib/timeline"; -import { buildDefaultEffectInstance } from "@/lib/effects"; - -function addEffectToElement({ - element, - effectType, -}: { - element: VisualElement; - effectType: string; -}): VisualElement { - const instance = buildDefaultEffectInstance({ effectType }); - const currentEffects = element.effects ?? []; - return { ...element, effects: [...currentEffects, instance] }; -} - -export class AddClipEffectCommand extends Command { - private savedState: SceneTracks | null = null; - private effectId: string | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectType: string; - - constructor({ - trackId, - elementId, - effectType, - }: { - trackId: string; - elementId: string; - effectType: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectType = effectType; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - const updated = addEffectToElement({ - element: element as VisualElement, - effectType: this.effectType, - }); - const effects = updated.effects ?? []; - this.effectId = effects[effects.length - 1]?.id ?? null; - return updated; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getEffectId(): string | null { - return this.effectId; - } -} diff --git a/apps/web/src/lib/commands/timeline/element/effects/index.ts b/apps/web/src/lib/commands/timeline/element/effects/index.ts deleted file mode 100644 index 133ce661..00000000 --- a/apps/web/src/lib/commands/timeline/element/effects/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { AddClipEffectCommand } from "./add-effect"; -export { RemoveClipEffectCommand } from "./remove-effect"; -export { ToggleClipEffectCommand } from "./toggle-effect"; -export { UpdateClipEffectParamsCommand } from "./update-effect-params"; -export { ReorderClipEffectsCommand } from "./reorder-effect"; diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts deleted file mode 100644 index f453f2cc..00000000 --- a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; -import type { SceneTracks, VisualElement } from "@/lib/timeline"; - -function removeEffectFromElement({ - element, - effectId, -}: { - element: VisualElement; - effectId: string; -}): VisualElement { - const currentEffects = element.effects ?? []; - const filtered = currentEffects.filter((effect) => effect.id !== effectId); - return { ...element, effects: filtered }; -} - -export class RemoveClipEffectCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - - constructor({ - trackId, - elementId, - effectId, - }: { - trackId: string; - elementId: string; - effectId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - return removeEffectFromElement({ - element: element as VisualElement, - effectId: this.effectId, - }); - }, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts deleted file mode 100644 index 28b7c4e1..00000000 --- a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; -import type { SceneTracks, VisualElement } from "@/lib/timeline"; - -function reorderEffectsOnElement({ - element, - fromIndex, - toIndex, -}: { - element: VisualElement; - fromIndex: number; - toIndex: number; -}): VisualElement { - const effects = [...(element.effects ?? [])]; - const [moved] = effects.splice(fromIndex, 1); - effects.splice(toIndex, 0, moved); - return { ...element, effects }; -} - -export class ReorderClipEffectsCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly fromIndex: number; - private readonly toIndex: number; - - constructor({ - trackId, - elementId, - fromIndex, - toIndex, - }: { - trackId: string; - elementId: string; - fromIndex: number; - toIndex: number; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.fromIndex = fromIndex; - this.toIndex = toIndex; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - return reorderEffectsOnElement({ - element: element as VisualElement, - fromIndex: this.fromIndex, - toIndex: this.toIndex, - }); - }, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts deleted file mode 100644 index a703cd94..00000000 --- a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; -import type { SceneTracks, VisualElement } from "@/lib/timeline"; - -export function toggleEffectOnElement({ - element, - effectId, -}: { - element: VisualElement; - effectId: string; -}): VisualElement { - const currentEffects = element.effects ?? []; - const updated = currentEffects.map((effect) => - effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect, - ); - return { ...element, effects: updated }; -} - -export class ToggleClipEffectCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - - constructor({ - trackId, - elementId, - effectId, - }: { - trackId: string; - elementId: string; - effectId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - return toggleEffectOnElement({ - element: element as VisualElement, - effectId: this.effectId, - }); - }, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts deleted file mode 100644 index 5e75c1f0..00000000 --- a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; -import type { ParamValues } from "@/lib/params"; -import type { SceneTracks, VisualElement } from "@/lib/timeline"; - -function updateEffectParamsOnElement({ - element, - effectId, - params, -}: { - element: VisualElement; - effectId: string; - params: Partial; -}): 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/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts deleted file mode 100644 index ff365801..00000000 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { InsertElementCommand } from "./insert-element"; -export { DeleteElementsCommand } from "./delete-elements"; -export { DuplicateElementsCommand } from "./duplicate-elements"; -export { SplitElementsCommand } from "./split-elements"; -export { UpdateElementsCommand } from "./update-elements"; -export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation"; -export { MoveElementCommand } from "./move-elements"; - -export * from "./keyframes"; -export * from "./effects"; -export * from "./masks"; diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts deleted file mode 100644 index ed05b8c6..00000000 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { - CreateTimelineElement, - SceneTracks, - TimelineElement, - TrackType, -} from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { requiresMediaId } from "@/lib/timeline/element-utils"; -import type { MediaAsset } from "@/lib/media/types"; -import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; -import { floatToFrameRate } from "@/lib/fps/utils"; -import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; -import { - applyPlacement, - canElementGoOnTrack, - resolveTrackPlacement, - validateElementTrackCompatibility, -} from "@/lib/timeline/placement"; - -type InsertElementPlacement = - | { mode: "explicit"; trackId: string } - | { mode: "auto"; trackType?: TrackType; insertIndex?: number }; - -export interface InsertElementParams { - element: CreateTimelineElement; - placement: InsertElementPlacement; -} - -export class InsertElementCommand extends Command { - private elementId: string; - private savedState: SceneTracks | null = null; - private targetTrackId: string | null = null; - - constructor({ element, placement }: InsertElementParams) { - super(); - this.elementId = generateUUID(); - this.element = element; - this.placement = placement; - } - - private element: CreateTimelineElement; - private placement: InsertElementPlacement; - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - if (!this.validateElementBasics({ element: this.element })) { - return; - } - - const totalElementsInTimeline = - this.savedState.main.elements.length + - this.savedState.overlay.reduce( - (total, track) => total + track.elements.length, - 0, - ) + - this.savedState.audio.reduce( - (total, track) => total + track.elements.length, - 0, - ); - const isFirstElement = totalElementsInTimeline === 0; - - const newElement = this.buildElement({ element: this.element }); - const updateResult = this.applyPlacementResult({ - tracks: this.savedState, - element: newElement, - }); - - if (!updateResult) { - return; - } - - const { updatedTracks, targetTrackId } = updateResult; - this.targetTrackId = targetTrackId; - - const isVisualMedia = - newElement.type === "video" || newElement.type === "image"; - - if (isFirstElement && isVisualMedia) { - const mediaAssets = editor.media.getAssets(); - const activeProject = editor.project.getActive(); - const asset = mediaAssets.find( - (item: MediaAsset) => item.id === newElement.mediaId, - ); - - if (asset?.width && asset?.height) { - const nextCanvasSize = { width: asset.width, height: asset.height }; - const shouldSetOriginalCanvasSize = - !activeProject?.settings.originalCanvasSize; - editor.project.updateSettings({ - settings: { - canvasSize: nextCanvasSize, - ...(shouldSetOriginalCanvasSize - ? { originalCanvasSize: nextCanvasSize } - : {}), - }, - pushHistory: false, - }); - } - - if (asset?.type === "video" && asset?.fps) { - editor.project.updateSettings({ - settings: { fps: floatToFrameRate(asset.fps) }, - pushHistory: false, - }); - } - } - - editor.timeline.updateTracks(updatedTracks); - - return { - selection: { - selectedElements: [ - { trackId: targetTrackId, elementId: this.elementId }, - ], - selectedKeyframes: [], - keyframeSelectionAnchor: null, - selectedMaskPoints: null, - }, - }; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getElementId(): string { - return this.elementId; - } - - getTrackId(): string | null { - return this.targetTrackId; - } - - private buildElement({ - element, - }: { - element: CreateTimelineElement; - }): TimelineElement { - return { - ...element, - id: this.elementId, - startTime: element.startTime, - trimStart: element.trimStart ?? 0, - trimEnd: element.trimEnd ?? 0, - duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION, - } as TimelineElement; - } - - private validateElementBasics({ - element, - }: { - element: CreateTimelineElement; - }): boolean { - if (requiresMediaId({ element }) && !("mediaId" in element)) { - console.error("Element requires mediaId"); - return false; - } - - if ( - element.type === "audio" && - element.sourceType === "library" && - !element.sourceUrl - ) { - console.error("Library audio element must have sourceUrl"); - return false; - } - - if (element.type === "sticker" && !element.stickerId) { - console.error("Sticker element must have stickerId"); - return false; - } - - if (element.type === "graphic") { - registerDefaultGraphics(); - if ( - !element.definitionId || - !graphicsRegistry.has(element.definitionId) - ) { - console.error("Graphic element must have a valid definitionId"); - return false; - } - } - - if (element.type === "text" && !element.content) { - console.error("Text element must have content"); - return false; - } - - if (element.type === "effect" && !element.effectType) { - console.error("Effect element must have effectType"); - return false; - } - - return true; - } - - private applyPlacementResult({ - tracks, - element, - }: { - tracks: SceneTracks; - element: TimelineElement; - }): { updatedTracks: SceneTracks; targetTrackId: string } | null { - const placement = this.placement; - - if ( - placement.mode === "auto" && - placement.trackType && - !canElementGoOnTrack({ - elementType: element.type, - trackType: placement.trackType, - }) - ) { - console.error( - `${element.type} elements cannot be placed on ${placement.trackType} tracks`, - ); - return null; - } - - const placementResult = resolveTrackPlacement({ - tracks, - ...(placement.mode === "auto" && placement.trackType - ? { trackType: placement.trackType } - : { elementType: element.type }), - timeSpans: [ - { - startTime: element.startTime, - duration: element.duration, - }, - ], - strategy: - placement.mode === "explicit" - ? { type: "explicit", trackId: placement.trackId } - : { type: "firstAvailable" }, - }); - if (!placementResult) { - if (placement.mode === "explicit") { - const targetTrack = - tracks.main.id === placement.trackId - ? tracks.main - : (tracks.overlay.find((track) => track.id === placement.trackId) ?? - tracks.audio.find((track) => track.id === placement.trackId)); - if (!targetTrack) { - console.error("Track not found:", placement.trackId); - return null; - } - - const validation = validateElementTrackCompatibility({ - element, - track: targetTrack, - }); - console.error(validation.errorMessage); - } - - return null; - } - - const elementToPlace = - placementResult.kind === "existingTrack" - ? { - ...element, - startTime: placementResult.adjustedStartTime ?? element.startTime, - } - : element; - - const appliedPlacement = applyPlacement({ - tracks, - placementResult, - elements: [elementToPlace], - newTrackInsertIndexOverride: - placement.mode === "auto" && typeof placement.insertIndex === "number" - ? placement.insertIndex - : undefined, - }); - if (!appliedPlacement) { - return null; - } - - return { - updatedTracks: appliedPlacement.updatedTracks, - targetTrackId: appliedPlacement.targetTrackId, - }; - } -} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts deleted file mode 100644 index 319a827b..00000000 --- a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./remove-effect-param-keyframe"; -export * from "./remove-keyframe"; -export * from "./retime-keyframe"; -export * from "./update-scalar-keyframe-curve"; -export * from "./upsert-effect-param-keyframe"; -export * from "./upsert-keyframe"; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts deleted file mode 100644 index b1ef4aef..00000000 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { EditorCore } from "@/core"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; -import { updateElementInSceneTracks } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { SceneTracks } from "@/lib/timeline"; - -export class RemoveEffectParamKeyframeCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - private readonly paramKey: string; - private readonly keyframeId: string; - - constructor({ - trackId, - elementId, - effectId, - paramKey, - keyframeId, - }: { - trackId: string; - elementId: string; - effectId: string; - paramKey: string; - keyframeId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - this.paramKey = paramKey; - this.keyframeId = keyframeId; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - const animations = removeEffectParamKeyframe({ - animations: element.animations, - effectId: this.effectId, - paramKey: this.paramKey, - keyframeId: this.keyframeId, - }); - return { ...element, animations }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts deleted file mode 100644 index 3c3d1c6b..00000000 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { EditorCore } from "@/core"; -import { - hasKeyframesForPath, - removeElementKeyframe, - resolveAnimationTarget, -} from "@/lib/animation"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { updateElementInSceneTracks } from "@/lib/timeline"; -import type { AnimationPath, AnimationValue } from "@/lib/animation/types"; -import type { SceneTracks, TimelineElement } from "@/lib/timeline"; - -function removeKeyframeAndPersist({ - element, - propertyPath, - keyframeId, - valueAtPlayhead, -}: { - element: TimelineElement; - propertyPath: AnimationPath; - keyframeId: string; - valueAtPlayhead: AnimationValue | null; -}): TimelineElement { - const target = resolveAnimationTarget({ element, path: propertyPath }); - if (!target) { - return element; - } - - const nextAnimations = removeElementKeyframe({ - animations: element.animations, - propertyPath, - keyframeId, - }); - - const isChannelNowEmpty = !hasKeyframesForPath({ - animations: nextAnimations, - propertyPath, - }); - - // When clearing all keyframes, preserve the value at the playhead (what the user was seeing) - const shouldPersistToBase = isChannelNowEmpty && valueAtPlayhead !== null; - - const baseElement = shouldPersistToBase - ? target.setBaseValue({ value: valueAtPlayhead }) - : element; - - return { ...baseElement, animations: nextAnimations }; -} - -export class RemoveKeyframeCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: AnimationPath; - private readonly keyframeId: string; - private readonly valueAtPlayhead: AnimationValue | null; - - constructor({ - trackId, - elementId, - propertyPath, - keyframeId, - valueAtPlayhead, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; - valueAtPlayhead: AnimationValue | null; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.keyframeId = keyframeId; - this.valueAtPlayhead = valueAtPlayhead; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => - removeKeyframeAndPersist({ - element, - propertyPath: this.propertyPath, - keyframeId: this.keyframeId, - valueAtPlayhead: this.valueAtPlayhead, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts deleted file mode 100644 index ad7f5651..00000000 --- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { EditorCore } from "@/core"; -import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { updateElementInSceneTracks } from "@/lib/timeline"; -import type { AnimationPath } from "@/lib/animation/types"; -import type { SceneTracks } from "@/lib/timeline"; - -export class RetimeKeyframeCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: AnimationPath; - private readonly keyframeId: string; - private readonly nextTime: number; - - constructor({ - trackId, - elementId, - propertyPath, - keyframeId, - nextTime, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; - nextTime: number; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.keyframeId = keyframeId; - this.nextTime = nextTime; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => { - if (!resolveAnimationTarget({ element, path: this.propertyPath })) { - return element; - } - - const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); - return { - ...element, - animations: retimeElementKeyframe({ - animations: element.animations, - propertyPath: this.propertyPath, - keyframeId: this.keyframeId, - time: boundedTime, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts deleted file mode 100644 index 9a4c2304..00000000 --- a/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { EditorCore } from "@/core"; -import { - resolveAnimationTarget, - updateScalarKeyframeCurve, -} from "@/lib/animation"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { updateElementInSceneTracks } from "@/lib/timeline"; -import type { - AnimationPath, - ScalarCurveKeyframePatch, -} from "@/lib/animation/types"; -import type { SceneTracks } from "@/lib/timeline"; - -export class UpdateScalarKeyframeCurveCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: AnimationPath; - private readonly componentKey: string; - private readonly keyframeId: string; - private readonly patch: ScalarCurveKeyframePatch; - - constructor({ - trackId, - elementId, - propertyPath, - componentKey, - keyframeId, - patch, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - componentKey: string; - keyframeId: string; - patch: ScalarCurveKeyframePatch; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.componentKey = componentKey; - this.keyframeId = keyframeId; - this.patch = patch; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => { - if (!resolveAnimationTarget({ element, path: this.propertyPath })) { - return element; - } - - return { - ...element, - animations: updateScalarKeyframeCurve({ - animations: element.animations, - propertyPath: this.propertyPath, - componentKey: this.componentKey, - keyframeId: this.keyframeId, - patch: this.patch, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts deleted file mode 100644 index f436721a..00000000 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { EditorCore } from "@/core"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { - buildEffectParamPath, - resolveAnimationTarget, - upsertPathKeyframe, -} from "@/lib/animation"; -import { updateElementInSceneTracks } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { AnimationInterpolation } from "@/lib/animation/types"; -import type { SceneTracks } from "@/lib/timeline"; - -export class UpsertEffectParamKeyframeCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - private readonly paramKey: string; - private readonly time: number; - private readonly value: number | string | boolean; - private readonly interpolation: AnimationInterpolation | undefined; - private readonly keyframeId: string | undefined; - - constructor({ - trackId, - elementId, - effectId, - paramKey, - time, - value, - interpolation, - keyframeId, - }: { - trackId: string; - elementId: string; - effectId: string; - paramKey: string; - time: number; - value: number | string | boolean; - interpolation?: AnimationInterpolation; - keyframeId?: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - this.paramKey = paramKey; - this.time = time; - this.value = value; - this.interpolation = interpolation; - this.keyframeId = keyframeId; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); - const propertyPath = buildEffectParamPath({ - effectId: this.effectId, - paramKey: this.paramKey, - }); - const target = resolveAnimationTarget({ - element, - path: propertyPath, - }); - if (!target) { - return element; - } - - const animations = upsertPathKeyframe({ - animations: element.animations, - propertyPath, - time: boundedTime, - value: this.value, - interpolation: this.interpolation, - keyframeId: this.keyframeId, - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, - coerceValue: target.coerceValue, - }); - return { ...element, animations }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts deleted file mode 100644 index f77e69b8..00000000 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { EditorCore } from "@/core"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation"; -import { updateElementInSceneTracks } from "@/lib/timeline"; -import type { SceneTracks } from "@/lib/timeline"; -import type { - AnimationPath, - AnimationInterpolation, - AnimationValue, -} from "@/lib/animation/types"; - -export class UpsertKeyframeCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: AnimationPath; - private readonly time: number; - private readonly value: AnimationValue; - private readonly interpolation: AnimationInterpolation | undefined; - private readonly keyframeId: string | undefined; - - constructor({ - trackId, - elementId, - propertyPath, - time, - value, - interpolation, - keyframeId, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.time = time; - this.value = value; - this.interpolation = interpolation; - this.keyframeId = keyframeId; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => { - const target = resolveAnimationTarget({ - element, - path: this.propertyPath, - }); - if (!target) { - return element; - } - - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); - return { - ...element, - animations: upsertPathKeyframe({ - animations: element.animations, - propertyPath: this.propertyPath, - time: boundedTime, - value: this.value, - interpolation: this.interpolation, - keyframeId: this.keyframeId, - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, - coerceValue: target.coerceValue, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} diff --git a/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts deleted file mode 100644 index 346eb8d9..00000000 --- a/apps/web/src/lib/commands/timeline/element/masks/delete-custom-mask-points.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { EditorCore } from "@/core"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { - getCustomMaskClosedStateAfterPointRemoval, - removeCustomMaskPoints, -} from "@/lib/masks/custom-path"; -import type { CustomMask } from "@/lib/masks/types"; -import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline"; -import type { MaskableElement, SceneTracks } from "@/lib/timeline"; - -function deletePointsFromCustomMask({ - mask, - pointIds, -}: { - mask: CustomMask; - pointIds: string[]; -}): CustomMask { - const points = mask.params.path; - const nextPoints = removeCustomMaskPoints({ points, pointIds }); - if (nextPoints.length === points.length) { - return mask; - } - - return { - ...mask, - params: { - ...mask.params, - path: nextPoints, - closed: getCustomMaskClosedStateAfterPointRemoval({ - wasClosed: mask.params.closed, - remainingPointCount: nextPoints.length, - }), - }, - }; -} - -function deletePointsFromElementMask({ - element, - maskId, - pointIds, -}: { - element: MaskableElement; - maskId: string; - pointIds: string[]; -}): { element: MaskableElement; didDeletePoints: boolean } { - const currentMasks = element.masks ?? []; - let didDeletePoints = false; - const nextMasks = currentMasks.map((mask) => { - if (mask.id !== maskId || mask.type !== "custom") { - return mask; - } - - const nextMask = deletePointsFromCustomMask({ - mask, - pointIds, - }); - didDeletePoints ||= nextMask !== mask; - return nextMask; - }); - - return { - element: didDeletePoints ? { ...element, masks: nextMasks } : element, - didDeletePoints, - }; -} - -export class DeleteCustomMaskPointsCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly maskId: string; - private readonly pointIds: string[]; - - constructor({ - trackId, - elementId, - maskId, - pointIds, - }: { - trackId: string; - elementId: string; - maskId: string; - pointIds: string[]; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.maskId = maskId; - this.pointIds = pointIds; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - let didDeletePoints = false; - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isMaskableElement, - update: (element) => { - const result = deletePointsFromElementMask({ - element: element as MaskableElement, - maskId: this.maskId, - pointIds: this.pointIds, - }); - didDeletePoints ||= result.didDeletePoints; - return result.element; - }, - }); - - if (didDeletePoints) { - editor.timeline.updateTracks(updatedTracks); - return { - selection: { - selectedMaskPoints: null, - }, - }; - } - - return undefined; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts deleted file mode 100644 index dd6373b1..00000000 --- a/apps/web/src/lib/commands/timeline/element/masks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points"; -export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point"; -export { RemoveMaskCommand } from "./remove-mask"; -export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts deleted file mode 100644 index 9f837836..00000000 --- a/apps/web/src/lib/commands/timeline/element/masks/insert-custom-mask-point.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { EditorCore } from "@/core"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { insertPointOnCustomMaskSegment } from "@/lib/masks/definitions/custom"; -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { CustomMask } from "@/lib/masks/types"; -import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline"; -import type { MaskableElement, SceneTracks } from "@/lib/timeline"; - -function insertPointIntoCustomMask({ - mask, - segmentIndex, - canvasPoint, - bounds, -}: { - mask: CustomMask; - segmentIndex: number; - canvasPoint: { x: number; y: number }; - bounds: ElementBounds; -}): { mask: CustomMask; insertedPointId: string | null } { - const result = insertPointOnCustomMaskSegment({ - params: mask.params, - segmentIndex, - canvasPoint, - bounds, - }); - if (!result) { - return { - mask, - insertedPointId: null, - }; - } - - return { - mask: { - ...mask, - params: result.params, - }, - insertedPointId: result.pointId, - }; -} - -function insertPointIntoElementMask({ - element, - maskId, - segmentIndex, - canvasPoint, - bounds, -}: { - element: MaskableElement; - maskId: string; - segmentIndex: number; - canvasPoint: { x: number; y: number }; - bounds: ElementBounds; -}): { - element: MaskableElement; - didInsertPoint: boolean; - insertedPointId: string | null; -} { - const currentMasks = element.masks ?? []; - let insertedPointId: string | null = null; - let didInsertPoint = false; - - const nextMasks = currentMasks.map((mask) => { - if (mask.id !== maskId || mask.type !== "custom") { - return mask; - } - - const result = insertPointIntoCustomMask({ - mask, - segmentIndex, - canvasPoint, - bounds, - }); - if (result.insertedPointId) { - insertedPointId = result.insertedPointId; - didInsertPoint = true; - } - return result.mask; - }); - - return { - element: didInsertPoint ? { ...element, masks: nextMasks } : element, - didInsertPoint, - insertedPointId, - }; -} - -export class InsertCustomMaskPointCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly maskId: string; - private readonly segmentIndex: number; - private readonly canvasPoint: { x: number; y: number }; - private readonly bounds: ElementBounds; - - constructor({ - trackId, - elementId, - maskId, - segmentIndex, - canvasPoint, - bounds, - }: { - trackId: string; - elementId: string; - maskId: string; - segmentIndex: number; - canvasPoint: { x: number; y: number }; - bounds: ElementBounds; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.maskId = maskId; - this.segmentIndex = segmentIndex; - this.canvasPoint = canvasPoint; - this.bounds = bounds; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - let didInsertPoint = false; - let insertedPointId: string | null = null; - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isMaskableElement, - update: (element) => { - const result = insertPointIntoElementMask({ - element: element as MaskableElement, - maskId: this.maskId, - segmentIndex: this.segmentIndex, - canvasPoint: this.canvasPoint, - bounds: this.bounds, - }); - didInsertPoint ||= result.didInsertPoint; - insertedPointId ??= result.insertedPointId; - return result.element; - }, - }); - - if (!didInsertPoint || !insertedPointId) { - return undefined; - } - - editor.timeline.updateTracks(updatedTracks); - return { - selection: { - selectedMaskPoints: { - trackId: this.trackId, - elementId: this.elementId, - maskId: this.maskId, - pointIds: [insertedPointId], - }, - }, - }; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts deleted file mode 100644 index 9f6c5f77..00000000 --- a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { EditorCore } from "@/core"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline"; -import type { SceneTracks, MaskableElement } from "@/lib/timeline"; - -function removeMaskFromElement({ - element, - maskId, -}: { - element: MaskableElement; - maskId: string; -}): MaskableElement { - const currentMasks = element.masks ?? []; - const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId); - return { ...element, masks: filteredMasks }; -} - -export class RemoveMaskCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly maskId: string; - - constructor({ - trackId, - elementId, - maskId, - }: { - trackId: string; - elementId: string; - maskId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.maskId = maskId; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isMaskableElement, - update: (element) => - removeMaskFromElement({ - element: element as MaskableElement, - maskId: this.maskId, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts deleted file mode 100644 index 2b5c1694..00000000 --- a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { EditorCore } from "@/core"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline"; -import type { Mask } from "@/lib/masks/types"; -import type { SceneTracks, MaskableElement } from "@/lib/timeline"; - -export function toggleMaskInvertedOnElement({ - element, - maskId, -}: { - element: MaskableElement; - maskId: string; -}): MaskableElement { - const currentMasks = element.masks ?? []; - const toggleMask = (mask: TMask): TMask => ({ - ...mask, - params: { - ...mask.params, - inverted: !mask.params.inverted, - }, - }); - const updatedMasks = currentMasks.map((mask) => - mask.id !== maskId ? mask : toggleMask(mask), - ); - - return { ...element, masks: updatedMasks }; -} - -export class ToggleMaskInvertedCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly maskId: string; - - constructor({ - trackId, - elementId, - maskId, - }: { - trackId: string; - elementId: string; - maskId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.maskId = maskId; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const updatedTracks = updateElementInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isMaskableElement, - update: (element) => - toggleMaskInvertedOnElement({ - element: element as MaskableElement, - maskId: this.maskId, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts deleted file mode 100644 index 7300f540..00000000 --- a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { EditorCore } from "@/core"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { - buildSeparatedAudioElement, - canExtractSourceAudio, - isSourceAudioSeparated, -} from "@/lib/timeline/audio-separation"; -import { - applyPlacement, - resolveTrackPlacement, -} from "@/lib/timeline/placement"; -import { updateElementInSceneTracks } from "@/lib/timeline/track-element-update"; -import type { - SceneTracks, - TimelineElement, - VideoElement, -} from "@/lib/timeline/types"; -import { generateUUID } from "@/utils/id"; - -export class ToggleSourceAudioSeparationCommand extends Command { - private savedState: SceneTracks | null = null; - - constructor( - private readonly params: { - trackId: string; - elementId: string; - }, - ) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const sourceTrack = [ - ...this.savedState.overlay, - this.savedState.main, - ...this.savedState.audio, - ].find((track) => track.id === this.params.trackId); - if (!sourceTrack) { - return; - } - const sourceElement = sourceTrack.elements.find( - (element) => element.id === this.params.elementId, - ) as TimelineElement | undefined; - if (!sourceElement || sourceElement.type !== "video") { - return; - } - const videoElement: VideoElement = sourceElement; - - if (isSourceAudioSeparated({ element: videoElement })) { - editor.timeline.updateTracks( - updateSourceAudioEnabled({ - tracks: this.savedState, - trackId: this.params.trackId, - elementId: this.params.elementId, - isSourceAudioEnabled: true, - }), - ); - return; - } - - const mediaAsset = editor.media - .getAssets() - .find((asset) => asset.id === videoElement.mediaId); - if (!canExtractSourceAudio(videoElement, mediaAsset)) { - return; - } - if (videoElement.duration <= 0) { - return; - } - - const separatedAudioElement = { - ...buildSeparatedAudioElement({ - sourceElement: videoElement, - }), - id: generateUUID(), - }; - const placementResult = resolveTrackPlacement({ - tracks: this.savedState, - trackType: "audio", - timeSpans: [ - { - startTime: separatedAudioElement.startTime, - duration: separatedAudioElement.duration, - }, - ], - strategy: { type: "firstAvailable" }, - }); - if (!placementResult) { - return; - } - const appliedPlacement = applyPlacement({ - tracks: this.savedState, - placementResult, - elements: [separatedAudioElement], - }); - if (!appliedPlacement) { - return; - } - - editor.timeline.updateTracks( - updateSourceAudioEnabled({ - tracks: appliedPlacement.updatedTracks, - trackId: this.params.trackId, - elementId: this.params.elementId, - isSourceAudioEnabled: false, - }), - ); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} - -function updateSourceAudioEnabled({ - tracks, - trackId, - elementId, - isSourceAudioEnabled, -}: { - tracks: SceneTracks; - trackId: string; - elementId: string; - isSourceAudioEnabled: boolean; -}): SceneTracks { - return updateElementInSceneTracks({ - tracks, - trackId, - elementId, - elementPredicate: (element): element is VideoElement => - element.type === "video", - update: (element) => ({ - ...element, - isSourceAudioEnabled, - }), - }); -} diff --git a/apps/web/src/lib/commands/timeline/element/update-elements.ts b/apps/web/src/lib/commands/timeline/element/update-elements.ts deleted file mode 100644 index 54d1ca88..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-elements.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { EditorCore } from "@/core"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { SceneTracks, TimelineElement } from "@/lib/timeline"; -import { - findTrackInSceneTracks, - updateElementInSceneTracks, -} from "@/lib/timeline"; -import { applyElementUpdate } from "@/lib/timeline/update-pipeline"; - -export class UpdateElementsCommand extends Command { - private savedState: SceneTracks | null = null; - private readonly updates: Array<{ - trackId: string; - elementId: string; - patch: Partial; - }>; - - 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/lib/commands/timeline/index.ts b/apps/web/src/lib/commands/timeline/index.ts deleted file mode 100644 index cb285d94..00000000 --- a/apps/web/src/lib/commands/timeline/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./track"; -export * from "./element"; -export * from "./clipboard"; - -export { TracksSnapshotCommand } from "./tracks-snapshot"; diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts deleted file mode 100644 index a577c693..00000000 --- a/apps/web/src/lib/commands/timeline/track/add-track.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { SceneTracks, TrackType } from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { EditorCore } from "@/core"; -import { - buildEmptyTrack, - getDefaultInsertIndexForTrack, -} from "@/lib/timeline/placement"; - -export class AddTrackCommand extends Command { - private trackId: string; - private savedState: SceneTracks | null = null; - - constructor( - private type: TrackType, - private index?: number, - ) { - super(); - this.trackId = generateUUID(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const insertIndex = - this.index ?? - getDefaultInsertIndexForTrack({ - tracks: this.savedState, - trackType: this.type, - }); - - const updatedTracks = - this.type === "audio" - ? buildAudioTrackState({ - tracks: this.savedState, - insertIndex, - trackId: this.trackId, - }) - : buildOverlayTrackState({ - tracks: this.savedState, - insertIndex, - trackId: this.trackId, - trackType: this.type, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getTrackId(): string { - return this.trackId; - } -} - -function buildAudioTrackState({ - tracks, - insertIndex, - trackId, -}: { - tracks: SceneTracks; - insertIndex: number; - trackId: string; -}): SceneTracks { - const audioInsertIndex = Math.max(0, insertIndex - tracks.overlay.length - 1); - const newTrack = buildEmptyTrack({ - id: trackId, - type: "audio", - }); - return { - ...tracks, - audio: [ - ...tracks.audio.slice(0, audioInsertIndex), - newTrack, - ...tracks.audio.slice(audioInsertIndex), - ], - }; -} - -function buildOverlayTrackState({ - tracks, - insertIndex, - trackId, - trackType, -}: { - tracks: SceneTracks; - insertIndex: number; - trackId: string; - trackType: Exclude; -}): SceneTracks { - const overlayInsertIndex = Math.min(insertIndex, tracks.overlay.length); - const newTrack = - trackType === "video" - ? buildEmptyTrack({ id: trackId, type: "video" }) - : trackType === "text" - ? buildEmptyTrack({ id: trackId, type: "text" }) - : trackType === "graphic" - ? buildEmptyTrack({ id: trackId, type: "graphic" }) - : buildEmptyTrack({ id: trackId, type: "effect" }); - return { - ...tracks, - overlay: [ - ...tracks.overlay.slice(0, overlayInsertIndex), - newTrack, - ...tracks.overlay.slice(overlayInsertIndex), - ], - }; -} diff --git a/apps/web/src/lib/commands/timeline/track/index.ts b/apps/web/src/lib/commands/timeline/track/index.ts deleted file mode 100644 index 9d4bd9c3..00000000 --- a/apps/web/src/lib/commands/timeline/track/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { AddTrackCommand } from "./add-track"; -export { RemoveTrackCommand } from "./remove-track"; -export { ToggleTrackMuteCommand } from "./toggle-track-mute"; -export { ToggleTrackVisibilityCommand } from "./toggle-track-visibility"; diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts deleted file mode 100644 index 5fcaa898..00000000 --- a/apps/web/src/lib/commands/timeline/track/remove-track.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { SceneTracks } from "@/lib/timeline"; - -export class RemoveTrackCommand extends Command { - private savedState: SceneTracks | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks: SceneTracks = { - ...this.savedState, - overlay: this.savedState.overlay.filter((track) => track.id !== this.trackId), - audio: this.savedState.audio.filter((track) => track.id !== this.trackId), - }; - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts deleted file mode 100644 index 7ffe2fd7..00000000 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { SceneTracks } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline"; - -export class ToggleTrackMuteCommand extends Command { - private savedState: SceneTracks | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const targetTrack = findTrackInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - }); - if (!targetTrack) { - return; - } - - const updatedTracks = updateTrackInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - update: (track) => - canTrackHaveAudio(track) ? { ...track, muted: !track.muted } : track, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts deleted file mode 100644 index 2292c83a..00000000 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { SceneTracks } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline"; - -export class ToggleTrackVisibilityCommand extends Command { - private savedState: SceneTracks | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.scenes.getActiveScene().tracks; - - const targetTrack = findTrackInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - }); - if (!targetTrack) { - return; - } - - const updatedTracks = updateTrackInSceneTracks({ - tracks: this.savedState, - trackId: this.trackId, - update: (track) => { - if (canTrackBeHidden(track)) { - return { ...track, hidden: !track.hidden }; - } - return track; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts deleted file mode 100644 index daed6a9e..00000000 --- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { SceneTracks } from "@/lib/timeline"; -import { EditorCore } from "@/core"; - -export class TracksSnapshotCommand extends Command { - constructor( - private before: SceneTracks, - private after: SceneTracks, - ) { - super(); - } - - execute(): CommandResult | undefined { - EditorCore.getInstance().timeline.updateTracks(this.after); - return undefined; - } - - undo(): void { - EditorCore.getInstance().timeline.updateTracks(this.before); - } -} diff --git a/apps/web/src/lib/db/index.ts b/apps/web/src/lib/db/index.ts deleted file mode 100644 index b1ffd119..00000000 --- a/apps/web/src/lib/db/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; -import * as schema from "./schema"; -import { webEnv } from "@/lib/env/web"; - -let _db: ReturnType | null = null; - -function getDb() { - if (!_db) { - const client = postgres(webEnv.DATABASE_URL); - _db = drizzle(client, { schema }); - } - - return _db; -} - -export const db = getDb(); - -export * from "./schema"; diff --git a/apps/web/src/lib/db/schema.ts b/apps/web/src/lib/db/schema.ts deleted file mode 100644 index 8f8be248..00000000 --- a/apps/web/src/lib/db/schema.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core"; - -export const users = pgTable("users", { - id: text("id").primaryKey(), - - // todo: implement fully anonymous sign-in for privacy - // we don't have any auth flows currently so this is fine for now - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified").default(false).notNull(), - image: text("image"), - createdAt: timestamp("created_at") - .$defaultFn(() => /* @__PURE__ */ new Date()) - .notNull(), - updatedAt: timestamp("updated_at") - .$defaultFn(() => /* @__PURE__ */ new Date()) - .notNull(), -}).enableRLS(); - -export const sessions = pgTable("sessions", { - id: text("id").primaryKey(), - expiresAt: timestamp("expires_at").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at").notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), -}).enableRLS(); - -export const accounts = pgTable("accounts", { - id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: timestamp("access_token_expires_at"), - refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), - scope: text("scope"), - password: text("password"), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at").notNull(), -}).enableRLS(); - -export const feedback = pgTable("feedback", { - id: text("id").primaryKey(), - message: text("message").notNull(), - createdAt: timestamp("created_at") - .$defaultFn(() => new Date()) - .notNull(), -}); - -export const verifications = pgTable("verifications", { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at").$defaultFn( - () => /* @__PURE__ */ new Date(), - ), - updatedAt: timestamp("updated_at").$defaultFn( - () => /* @__PURE__ */ new Date(), - ), -}).enableRLS(); diff --git a/apps/web/src/lib/diagnostics/types.ts b/apps/web/src/lib/diagnostics/types.ts deleted file mode 100644 index 486f362c..00000000 --- a/apps/web/src/lib/diagnostics/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type DiagnosticSeverity = "caution" | "error"; - -export interface DiagnosticDefinition { - id: string; - scope: string; - severity: DiagnosticSeverity; - message: string; -} diff --git a/apps/web/src/lib/drag-data.ts b/apps/web/src/lib/drag-data.ts deleted file mode 100644 index 5cbcfbe0..00000000 --- a/apps/web/src/lib/drag-data.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { TimelineDragData } from "@/lib/timeline/drag"; - -const MIME_TYPE = "application/x-timeline-drag"; -let lastDragData: TimelineDragData | null = null; - -export function setDragData({ - dataTransfer, - dragData, -}: { - dataTransfer: DataTransfer; - dragData: TimelineDragData; -}): void { - dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData)); - dataTransfer.setData("text/plain", JSON.stringify(dragData)); - lastDragData = dragData; -} - -export function getDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): TimelineDragData | null { - const data = dataTransfer.getData(MIME_TYPE); - if (data) return JSON.parse(data) as TimelineDragData; - - const textData = dataTransfer.getData("text/plain"); - if (textData) { - try { - return JSON.parse(textData) as TimelineDragData; - } catch { - return lastDragData; - } - } - - return lastDragData; -} - -export function hasDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): boolean { - return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null; -} - -export function clearDragData(): void { - lastDragData = null; -} diff --git a/apps/web/src/lib/effects/definitions/blur.ts b/apps/web/src/lib/effects/definitions/blur.ts deleted file mode 100644 index b464db3a..00000000 --- a/apps/web/src/lib/effects/definitions/blur.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { EffectDefinition, EffectPass } from "@/lib/effects/types"; - -export const GAUSSIAN_BLUR_SHADER = "gaussian-blur"; - -const MAX_SINGLE_PASS_SIGMA = 10; -const MAX_STEP = 4; -const MAX_EFFECTIVE_SIGMA = MAX_SINGLE_PASS_SIGMA * MAX_STEP; -const MAX_ITERATIONS = 8; - -export function buildGaussianBlurPasses({ - sigmaX, - sigmaY, -}: { - sigmaX: number; - sigmaY: number; -}): EffectPass[] { - const maxSigma = Math.max(sigmaX, sigmaY); - if (maxSigma < 0.001) return []; - - const iterations = Math.min( - MAX_ITERATIONS, - Math.max( - 1, - Math.ceil( - (maxSigma * maxSigma) / - (MAX_EFFECTIVE_SIGMA * MAX_EFFECTIVE_SIGMA), - ), - ), - ); - const perPassSigmaX = sigmaX / Math.sqrt(iterations); - const perPassSigmaY = sigmaY / Math.sqrt(iterations); - const stepX = Math.max(1, perPassSigmaX / MAX_SINGLE_PASS_SIGMA); - const stepY = Math.max(1, perPassSigmaY / MAX_SINGLE_PASS_SIGMA); - - const passes: EffectPass[] = []; - for (let i = 0; i < iterations; i++) { - passes.push({ - shader: GAUSSIAN_BLUR_SHADER, - uniforms: { - u_sigma: perPassSigmaX, - u_step: stepX, - u_direction: [1, 0], - }, - }); - passes.push({ - shader: GAUSSIAN_BLUR_SHADER, - uniforms: { - u_sigma: perPassSigmaY, - u_step: stepY, - u_direction: [0, 1], - }, - }); - } - return passes; -} - -export const INTENSITY_TO_SIGMA_DIVISOR = 5; - -export function intensityToSigma({ intensity, resolution, reference }: { intensity: number; resolution: number; reference: number }): number { - return (intensity / INTENSITY_TO_SIGMA_DIVISOR) * (resolution / reference); -} - -function parseIntensity(effectParams: Record): number { - const raw = effectParams.intensity; - return typeof raw === "number" ? raw : Number.parseFloat(String(raw)); -} - -export const blurEffectDefinition: EffectDefinition = { - type: "blur", - name: "Blur", - keywords: ["blur", "soft", "defocus"], - params: [ - { - key: "intensity", - label: "Intensity", - type: "number", - default: 15, - min: 0, - max: 100, - step: 1, - }, - ], - renderer: { - passes: [ - { - shader: GAUSSIAN_BLUR_SHADER, - uniforms: ({ effectParams, width }) => ({ - u_sigma: Math.max(intensityToSigma({ intensity: parseIntensity(effectParams), resolution: width, reference: 1920 }), 0.001), - u_step: 1, - u_direction: [1, 0], - }), - }, - { - shader: GAUSSIAN_BLUR_SHADER, - uniforms: ({ effectParams, height }) => ({ - u_sigma: Math.max(intensityToSigma({ intensity: parseIntensity(effectParams), resolution: height, reference: 1080 }), 0.001), - u_step: 1, - u_direction: [0, 1], - }), - }, - ], - buildPasses: ({ effectParams, width, height }) => { - const intensity = parseIntensity(effectParams); - return buildGaussianBlurPasses({ - sigmaX: intensityToSigma({ intensity, resolution: width, reference: 1920 }), - sigmaY: intensityToSigma({ intensity, resolution: height, reference: 1080 }), - }); - }, - }, -}; diff --git a/apps/web/src/lib/effects/definitions/index.ts b/apps/web/src/lib/effects/definitions/index.ts deleted file mode 100644 index edc8c5e2..00000000 --- a/apps/web/src/lib/effects/definitions/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { effectsRegistry } from "../registry"; -import { blurEffectDefinition } from "./blur"; - -const defaultEffects = [blurEffectDefinition]; - -export function registerDefaultEffects(): void { - for (const definition of defaultEffects) { - if (effectsRegistry.has(definition.type)) { - continue; - } - effectsRegistry.register(definition.type, definition); - } -} diff --git a/apps/web/src/lib/effects/index.ts b/apps/web/src/lib/effects/index.ts deleted file mode 100644 index 92484ee2..00000000 --- a/apps/web/src/lib/effects/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { generateUUID } from "@/utils/id"; -import { buildDefaultParamValues } from "@/lib/registry"; -import { effectsRegistry } from "./registry"; -import type { ParamValues } from "@/lib/params"; -import type { Effect, EffectDefinition, EffectPass } from "@/lib/effects/types"; -import { VISUAL_ELEMENT_TYPES } from "@/lib/timeline"; - -export { effectsRegistry } from "./registry"; -export { registerDefaultEffects } from "./definitions"; - -export function resolveEffectPasses({ - definition, - effectParams, - width, - height, -}: { - definition: EffectDefinition; - effectParams: ParamValues; - width: number; - height: number; -}): EffectPass[] { - if (definition.renderer.buildPasses) { - return definition.renderer.buildPasses({ effectParams, width, height }); - } - return definition.renderer.passes.map((pass) => ({ - shader: pass.shader, - uniforms: pass.uniforms({ effectParams, width, height }), - })); -} - -export const EFFECT_TARGET_ELEMENT_TYPES = VISUAL_ELEMENT_TYPES; - -export function buildDefaultEffectInstance({ - effectType, -}: { - effectType: string; -}): Effect { - const definition = effectsRegistry.get(effectType); - const params: ParamValues = buildDefaultParamValues(definition.params); - - return { - id: generateUUID(), - type: effectType, - params, - enabled: true, - }; -} diff --git a/apps/web/src/lib/effects/registry.ts b/apps/web/src/lib/effects/registry.ts deleted file mode 100644 index 29cc3202..00000000 --- a/apps/web/src/lib/effects/registry.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DefinitionRegistry } from "@/lib/registry"; -import type { EffectDefinition } from "@/lib/effects/types"; - -export class EffectsRegistry extends DefinitionRegistry { - constructor() { - super("effect"); - } -} - -export const effectsRegistry = new EffectsRegistry(); diff --git a/apps/web/src/lib/effects/types.ts b/apps/web/src/lib/effects/types.ts deleted file mode 100644 index 1862b440..00000000 --- a/apps/web/src/lib/effects/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ParamDefinition, ParamValues } from "@/lib/params"; - -export interface Effect { - id: string; - type: string; - params: ParamValues; - enabled: boolean; -} - -export type EffectUniformValue = number | number[]; - -export interface EffectPass { - shader: string; - uniforms: Record; -} - -export interface EffectPassTemplate { - shader: string; - uniforms(params: { - effectParams: ParamValues; - width: number; - height: number; - }): Record; -} - -export interface EffectRendererConfig { - passes: EffectPassTemplate[]; - buildPasses?: (params: { - effectParams: ParamValues; - width: number; - height: number; - }) => EffectPass[]; -} - -export interface EffectDefinition { - type: string; - name: string; - keywords: string[]; - params: ParamDefinition[]; - renderer: EffectRendererConfig; -} diff --git a/apps/web/src/lib/env/web.ts b/apps/web/src/lib/env/web.ts deleted file mode 100644 index aa051615..00000000 --- a/apps/web/src/lib/env/web.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from "zod"; - -const webEnvSchema = z.object({ - // Node - NODE_ENV: z.enum(["development", "production", "test"]), - ANALYZE: z.string().optional(), - NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(), - - // Public - NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"), - NEXT_PUBLIC_MARBLE_API_URL: z.url(), - - // Server - DATABASE_URL: z.string().refine( - (url) => - url.startsWith("postgres://") || url.startsWith("postgresql://"), - "DATABASE_URL must be a postgres:// or postgresql:// URL", - ), - - BETTER_AUTH_SECRET: z.string(), - UPSTASH_REDIS_REST_URL: z.url(), - UPSTASH_REDIS_REST_TOKEN: z.string(), - MARBLE_WORKSPACE_KEY: z.string(), - FREESOUND_CLIENT_ID: z.string(), - FREESOUND_API_KEY: z.string(), -}); - -export type WebEnv = z.infer; - -export const webEnv = webEnvSchema.parse(process.env); diff --git a/apps/web/src/lib/export/defaults.ts b/apps/web/src/lib/export/defaults.ts deleted file mode 100644 index 0247f8a8..00000000 --- a/apps/web/src/lib/export/defaults.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ExportOptions } from "./index"; - -export const DEFAULT_EXPORT_OPTIONS = { - format: "mp4", - quality: "high", - includeAudio: true, -} satisfies ExportOptions; diff --git a/apps/web/src/lib/export/index.ts b/apps/web/src/lib/export/index.ts deleted file mode 100644 index c5c522b3..00000000 --- a/apps/web/src/lib/export/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { FrameRate } from "opencut-wasm"; -import { EXPORT_MIME_TYPES } from "./mime-types"; - -export const EXPORT_QUALITY_VALUES = [ - "low", - "medium", - "high", - "very_high", -] as const; - -export const EXPORT_FORMAT_VALUES = ["mp4", "webm"] as const; - -export type ExportFormat = (typeof EXPORT_FORMAT_VALUES)[number]; -export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number]; - -export interface ExportOptions { - format: ExportFormat; - quality: ExportQuality; - fps?: FrameRate; - includeAudio?: boolean; -} - -export interface ExportResult { - success: boolean; - buffer?: ArrayBuffer; - error?: string; - cancelled?: boolean; -} - -export interface ExportState { - isExporting: boolean; - progress: number; - result: ExportResult | null; -} - -export function getExportMimeType({ - format, -}: { - format: ExportFormat; -}): string { - return EXPORT_MIME_TYPES[format]; -} - -export function getExportFileExtension({ - format, -}: { - format: ExportFormat; -}): string { - return `.${format}`; -} - -export function downloadBuffer({ - buffer, - filename, - mimeType, -}: { - buffer: ArrayBuffer; - filename: string; - mimeType: string; -}): void { - const blob = new Blob([buffer], { type: mimeType }); - const url = URL.createObjectURL(blob); - const downloadLink = document.createElement("a"); - downloadLink.href = url; - downloadLink.download = filename; - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); - URL.revokeObjectURL(url); -} diff --git a/apps/web/src/lib/export/mime-types.ts b/apps/web/src/lib/export/mime-types.ts deleted file mode 100644 index b98900bf..00000000 --- a/apps/web/src/lib/export/mime-types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const EXPORT_MIME_TYPES = { - webm: "video/webm", - mp4: "video/mp4", -} as const; diff --git a/apps/web/src/lib/feedback/components/feedback-popover.tsx b/apps/web/src/lib/feedback/components/feedback-popover.tsx deleted file mode 100644 index f94b7253..00000000 --- a/apps/web/src/lib/feedback/components/feedback-popover.tsx +++ /dev/null @@ -1,242 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { ClockIcon } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; -import { Spinner } from "@/components/ui/spinner"; -import { - Form, - FormField, - FormItem, - FormControl, - clearFormDraft, -} from "@/components/ui/form"; -import type { FeedbackEntry } from "../types"; - -const PERSIST_KEY = "feedback-draft"; -const HISTORY_KEY = "feedback-history"; -const MAX_HISTORY = 20; - -interface FeedbackFormValues { - message: string; -} - -function readHistory(): FeedbackEntry[] { - try { - const stored = localStorage.getItem(HISTORY_KEY); - return stored ? (JSON.parse(stored) as FeedbackEntry[]) : []; - } catch { - return []; - } -} - -function writeHistory({ entries }: { entries: FeedbackEntry[] }): void { - try { - localStorage.setItem(HISTORY_KEY, JSON.stringify(entries)); - } catch { - // localStorage may be full or unavailable - } -} - -function useFeedback() { - const [entries, setEntries] = useState(readHistory); - const [isSubmitting, setIsSubmitting] = useState(false); - - async function submit({ - values, - onSuccess, - }: { - values: FeedbackFormValues; - onSuccess: () => void; - }) { - if (isSubmitting) return; - setIsSubmitting(true); - - try { - const res = await fetch("/api/feedback", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(values), - }); - - if (!res.ok) { - const data = await res.json().catch(() => null); - throw new Error(data?.error ?? "Failed to submit"); - } - - const { entry } = await res.json(); - const next = [entry, ...entries].slice(0, MAX_HISTORY); - setEntries(next); - writeHistory({ entries: next }); - onSuccess(); - toast.success("Feedback sent"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to send feedback", - ); - } finally { - setIsSubmitting(false); - } - } - - return { entries, isSubmitting, submit }; -} - -export function FeedbackPopover() { - const [open, setOpen] = useState(false); - - return ( - - - - - - setOpen(false)} /> - - - ); -} - -type View = "compose" | "history"; - -function FeedbackPopoverContent({ onClose }: { onClose: () => void }) { - const { entries, isSubmitting, submit } = useFeedback(); - const [view, setView] = useState("compose"); - - const form = useForm({ - defaultValues: { message: "" }, - }); - - async function handleSubmit(values: FeedbackFormValues) { - await submit({ - values, - onSuccess: () => { - form.reset({ message: "" }); - clearFormDraft({ key: PERSIST_KEY }); - onClose(); - }, - }); - } - - if (view === "history") { - return ( -
-
- {entries.map((entry) => ( - - ))} -
-
- -
-
- ); - } - - return ( -
-
- - ( - - -