summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/CONTRIBUTING.md6
-rw-r--r--.github/copilot-instructions.md686
-rw-r--r--.gitignore4
-rw-r--r--.prettierignore6
-rw-r--r--.prettierrc.json3
-rw-r--r--.vscode/settings.json11
-rw-r--r--AGENTS.md14
-rw-r--r--Cargo.lock2
-rw-r--r--README.md307
-rw-r--r--apps/web/package.json8
-rw-r--r--apps/web/src/actions/components/shortcuts-dialog.tsx13
-rw-r--r--apps/web/src/actions/definitions.ts75
-rw-r--r--apps/web/src/actions/keybinding.ts122
-rw-r--r--apps/web/src/actions/keybindings-store.ts195
-rw-r--r--apps/web/src/actions/keybindings/__tests__/persistence.test.ts144
-rw-r--r--apps/web/src/actions/keybindings/migrations/v2-to-v3.ts16
-rw-r--r--apps/web/src/actions/keybindings/migrations/v3-to-v4.ts17
-rw-r--r--apps/web/src/actions/keybindings/migrations/v4-to-v5.ts10
-rw-r--r--apps/web/src/actions/keybindings/migrations/v5-to-v6.ts10
-rw-r--r--apps/web/src/actions/keybindings/migrations/v6-to-v7.ts14
-rw-r--r--apps/web/src/actions/keybindings/persisted-state.ts37
-rw-r--r--apps/web/src/actions/keybindings/persistence.ts119
-rw-r--r--apps/web/src/actions/registry.ts3
-rw-r--r--apps/web/src/actions/types.ts88
-rw-r--r--apps/web/src/actions/use-action-handler.ts1
-rw-r--r--apps/web/src/actions/use-editor-actions.ts140
-rw-r--r--apps/web/src/actions/use-keybindings.ts2
-rw-r--r--apps/web/src/actions/use-keyboard-shortcuts-help.ts26
-rw-r--r--apps/web/src/animation/__tests__/animated-params.test.ts70
-rw-r--r--apps/web/src/animation/__tests__/binding-values.test.ts26
-rw-r--r--apps/web/src/animation/animated-params.ts83
-rw-r--r--apps/web/src/animation/binding-values.ts344
-rw-r--r--apps/web/src/animation/channel-data.ts57
-rw-r--r--apps/web/src/animation/curve-bridge.ts5
-rw-r--r--apps/web/src/animation/effect-param-channel.ts2
-rw-r--r--apps/web/src/animation/graph-channels.ts37
-rw-r--r--apps/web/src/animation/graphic-param-channel.ts2
-rw-r--r--apps/web/src/animation/index.ts8
-rw-r--r--apps/web/src/animation/interpolation.ts108
-rw-r--r--apps/web/src/animation/keyframe-query.ts226
-rw-r--r--apps/web/src/animation/keyframes.ts781
-rw-r--r--apps/web/src/animation/resolve.ts146
-rw-r--r--apps/web/src/animation/types.ts145
-rw-r--r--apps/web/src/app/brand/page.tsx2
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx19
-rw-r--r--apps/web/src/app/privacy/page.tsx11
-rw-r--r--apps/web/src/app/terms/page.tsx28
-rw-r--r--apps/web/src/clipboard/handlers/elements.ts2
-rw-r--r--apps/web/src/clipboard/handlers/index.ts2
-rw-r--r--apps/web/src/clipboard/handlers/keyframes.ts24
-rw-r--r--apps/web/src/clipboard/types.ts17
-rw-r--r--apps/web/src/commands/media/add-media-asset.ts25
-rw-r--r--apps/web/src/commands/media/remove-media-asset.ts16
-rw-r--r--apps/web/src/commands/scene/create-scene.ts16
-rw-r--r--apps/web/src/commands/scene/move-bookmark.ts17
-rw-r--r--apps/web/src/commands/scene/remove-bookmark.ts5
-rw-r--r--apps/web/src/commands/scene/rename-scene.ts16
-rw-r--r--apps/web/src/commands/scene/toggle-bookmark.ts5
-rw-r--r--apps/web/src/commands/scene/update-bookmark.ts17
-rw-r--r--apps/web/src/commands/timeline/clipboard/paste-keyframes.ts27
-rw-r--r--apps/web/src/commands/timeline/clipboard/paste.ts45
-rw-r--r--apps/web/src/commands/timeline/element/duplicate-elements.ts3
-rw-r--r--apps/web/src/commands/timeline/element/insert-element.ts10
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts9
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts15
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts18
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts24
-rw-r--r--apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts27
-rw-r--r--apps/web/src/commands/timeline/element/masks/index.ts4
-rw-r--r--apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts21
-rw-r--r--apps/web/src/commands/timeline/element/masks/remove-mask.ts10
-rw-r--r--apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts10
-rw-r--r--apps/web/src/commands/timeline/element/split-elements.ts64
-rw-r--r--apps/web/src/commands/timeline/track/add-track.ts16
-rw-r--r--apps/web/src/commands/timeline/tracks-snapshot.ts16
-rw-r--r--apps/web/src/components/editable-timecode.tsx25
-rw-r--r--apps/web/src/components/editor/panels/assets/assets-panel-store.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx481
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx13
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/index.tsx32
-rw-r--r--apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx154
-rw-r--r--apps/web/src/components/editor/panels/properties/components/property-param-field.tsx38
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts17
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts95
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts174
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts35
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts43
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx7
-rw-r--r--apps/web/src/components/editor/panels/properties/registry.tsx67
-rw-r--r--apps/web/src/components/editor/panels/properties/stores/properties-store.ts9
-rw-r--r--apps/web/src/core/index.ts2
-rw-r--r--apps/web/src/core/managers/clipboard-manager.ts5
-rw-r--r--apps/web/src/core/managers/media-manager.ts12
-rw-r--r--apps/web/src/core/managers/playback-manager.ts73
-rw-r--r--apps/web/src/core/managers/save-manager.ts13
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts30
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts67
-rw-r--r--apps/web/src/editor/panel-store.ts4
-rw-r--r--apps/web/src/editor/use-editor.ts36
-rw-r--r--apps/web/src/effects/definitions/index.ts5
-rw-r--r--apps/web/src/fps/utils.ts15
-rw-r--r--apps/web/src/graphics/components/graphic-tab.tsx10
-rw-r--r--apps/web/src/graphics/definitions/index.ts5
-rw-r--r--apps/web/src/graphics/index.ts18
-rw-r--r--apps/web/src/hooks/use-committed-ref.ts11
-rw-r--r--apps/web/src/hooks/use-focus-lock.ts15
-rw-r--r--apps/web/src/masks/__tests__/snap.test.ts130
-rw-r--r--apps/web/src/masks/builtin/box-like.ts (renamed from apps/web/src/masks/definitions/box-like.ts)37
-rw-r--r--apps/web/src/masks/builtin/definitions/cinematic-bars.ts (renamed from apps/web/src/masks/definitions/cinematic-bars.ts)79
-rw-r--r--apps/web/src/masks/builtin/definitions/diamond.ts (renamed from apps/web/src/masks/definitions/diamond.ts)64
-rw-r--r--apps/web/src/masks/builtin/definitions/ellipse.ts81
-rw-r--r--apps/web/src/masks/builtin/definitions/heart.ts (renamed from apps/web/src/masks/definitions/heart.ts)64
-rw-r--r--apps/web/src/masks/builtin/definitions/index.ts (renamed from apps/web/src/masks/definitions/index.ts)18
-rw-r--r--apps/web/src/masks/builtin/definitions/rectangle.ts (renamed from apps/web/src/masks/definitions/rectangle.ts)64
-rw-r--r--apps/web/src/masks/builtin/definitions/split.ts (renamed from apps/web/src/masks/definitions/split.ts)245
-rw-r--r--apps/web/src/masks/builtin/definitions/star.ts (renamed from apps/web/src/masks/definitions/star.ts)64
-rw-r--r--apps/web/src/masks/builtin/definitions/text.ts (renamed from apps/web/src/masks/definitions/text.ts)143
-rw-r--r--apps/web/src/masks/components/masks-tab.tsx53
-rw-r--r--apps/web/src/masks/definitions/ellipse.ts75
-rw-r--r--apps/web/src/masks/freeform/definition.ts (renamed from apps/web/src/masks/definitions/custom.ts)276
-rw-r--r--apps/web/src/masks/freeform/path.ts (renamed from apps/web/src/masks/custom-path.ts)203
-rw-r--r--apps/web/src/masks/handle-positions.ts71
-rw-r--r--apps/web/src/masks/index.ts76
-rw-r--r--apps/web/src/masks/registry.ts75
-rw-r--r--apps/web/src/masks/snap.ts179
-rw-r--r--apps/web/src/masks/types.ts169
-rw-r--r--apps/web/src/masks/use-mask-handles.ts128
-rw-r--r--apps/web/src/media/audio.ts13
-rw-r--r--apps/web/src/media/use-paste-media.ts10
-rw-r--r--apps/web/src/params/__tests__/channel-layout.test.ts10
-rw-r--r--apps/web/src/params/index.ts392
-rw-r--r--apps/web/src/params/registry.ts478
-rw-r--r--apps/web/src/preview/components/context-menu.tsx6
-rw-r--r--apps/web/src/preview/components/index.tsx26
-rw-r--r--apps/web/src/preview/components/mask-handles.tsx55
-rw-r--r--apps/web/src/preview/components/preview-viewport.tsx16
-rw-r--r--apps/web/src/preview/components/text-edit-overlay.tsx46
-rw-r--r--apps/web/src/preview/components/toolbar.tsx5
-rw-r--r--apps/web/src/preview/controllers/preview-interaction-controller.ts19
-rw-r--r--apps/web/src/preview/controllers/transform-handle-controller.ts87
-rw-r--r--apps/web/src/preview/element-bounds.ts9
-rw-r--r--apps/web/src/preview/hooks/use-preview-interaction.ts18
-rw-r--r--apps/web/src/preview/hooks/use-transform-handles.ts16
-rw-r--r--apps/web/src/project/types.ts5
-rw-r--r--apps/web/src/rendering/components/blending-tab.tsx230
-rw-r--r--apps/web/src/rendering/components/transform-tab.tsx462
-rw-r--r--apps/web/src/rendering/index.ts70
-rw-r--r--apps/web/src/selection/hooks/use-box-select.ts85
-rw-r--r--apps/web/src/selection/hooks/use-selection-scope.ts19
-rw-r--r--apps/web/src/selection/selectable-item.tsx10
-rw-r--r--apps/web/src/selection/selectable-surface.tsx26
-rw-r--r--apps/web/src/selection/selection-box.tsx47
-rw-r--r--apps/web/src/selection/types.ts7
-rw-r--r--apps/web/src/services/renderer/canvas-renderer.ts41
-rw-r--r--apps/web/src/services/renderer/canvas-utils.ts19
-rw-r--r--apps/web/src/services/renderer/compositor/frame-descriptor.ts147
-rw-r--r--apps/web/src/services/renderer/effect-preview.ts25
-rw-r--r--apps/web/src/services/renderer/gpu-renderer.ts53
-rw-r--r--apps/web/src/services/renderer/mask-feather.ts6
-rw-r--r--apps/web/src/services/renderer/nodes/graphic-node.ts17
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts11
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts5
-rw-r--r--apps/web/src/services/renderer/resolve.ts25
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts32
-rw-r--r--apps/web/src/services/storage/indexeddb-adapter.ts18
-rw-r--r--apps/web/src/services/storage/migrations/AGENTS.md10
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/helpers.ts39
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts7
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts41
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts9
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts13
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts17
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts21
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts17
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts11
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts13
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts20
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts15
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts252
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts135
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts139
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts25
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts129
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts25
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts17
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts26
-rw-r--r--apps/web/src/services/storage/migrations/index.ts10
-rw-r--r--apps/web/src/services/storage/migrations/transformers/index.ts1
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts104
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts5
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts29
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts228
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts226
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts138
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts15
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts100
-rw-r--r--apps/web/src/services/storage/migrations/v27-to-v28.ts14
-rw-r--r--apps/web/src/services/storage/migrations/v28-to-v29.ts14
-rw-r--r--apps/web/src/services/storage/migrations/v29-to-v30.ts14
-rw-r--r--apps/web/src/services/storage/migrations/v30-to-v31.ts14
-rw-r--r--apps/web/src/services/storage/opfs-adapter.ts8
-rw-r--r--apps/web/src/services/storage/service.ts86
-rw-r--r--apps/web/src/services/storage/types.ts2
-rw-r--r--apps/web/src/sounds/sounds-store.ts3
-rw-r--r--apps/web/src/stickers/providers/index.ts48
-rw-r--r--apps/web/src/subtitles/build-subtitle-text-element.ts85
-rw-r--r--apps/web/src/subtitles/components/assets-view.tsx2
-rw-r--r--apps/web/src/subtitles/insert.ts2
-rw-r--r--apps/web/src/subtitles/types.ts16
-rw-r--r--apps/web/src/text/background.ts10
-rw-r--r--apps/web/src/text/components/assets-view.tsx93
-rw-r--r--apps/web/src/text/components/text-tab.tsx767
-rw-r--r--apps/web/src/text/layout.ts9
-rw-r--r--apps/web/src/text/measure-element.ts202
-rw-r--r--apps/web/src/text/primitives.ts2
-rw-r--r--apps/web/src/timeline/__tests__/update-pipeline.test.ts72
-rw-r--r--apps/web/src/timeline/animation-properties.ts425
-rw-r--r--apps/web/src/timeline/animation-snap-points.ts3
-rw-r--r--apps/web/src/timeline/animation-targets.ts214
-rw-r--r--apps/web/src/timeline/audio-separation/index.ts30
-rw-r--r--apps/web/src/timeline/audio-state.ts21
-rw-r--r--apps/web/src/timeline/bookmarks/components/bookmarks.tsx43
-rw-r--r--apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts49
-rw-r--r--apps/web/src/timeline/bookmarks/preview-overlay-source.tsx5
-rw-r--r--apps/web/src/timeline/bookmarks/snap-source.ts3
-rw-r--r--apps/web/src/timeline/bookmarks/utils.ts32
-rw-r--r--apps/web/src/timeline/components/audio-tab.tsx151
-rw-r--r--apps/web/src/timeline/components/audio-volume-line.tsx16
-rw-r--r--apps/web/src/timeline/components/audio-waveform.tsx60
-rw-r--r--apps/web/src/timeline/components/drop-target.ts33
-rw-r--r--apps/web/src/timeline/components/graph-editor/bezier-graph.tsx5
-rw-r--r--apps/web/src/timeline/components/graph-editor/session.ts15
-rw-r--r--apps/web/src/timeline/components/index.tsx60
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx97
-rw-r--r--apps/web/src/timeline/components/timeline-playhead.tsx44
-rw-r--r--apps/web/src/timeline/components/timeline-ruler.tsx19
-rw-r--r--apps/web/src/timeline/components/timeline-track.tsx6
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts20
-rw-r--r--apps/web/src/timeline/controllers/element-interaction-controller.ts126
-rw-r--r--apps/web/src/timeline/controllers/keyframe-drag-controller.ts69
-rw-r--r--apps/web/src/timeline/controllers/playhead-controller.ts30
-rw-r--r--apps/web/src/timeline/controllers/resize-controller.ts80
-rw-r--r--apps/web/src/timeline/controllers/seek-controller.ts22
-rw-r--r--apps/web/src/timeline/controllers/zoom-controller.ts34
-rw-r--r--apps/web/src/timeline/creation.ts23
-rw-r--r--apps/web/src/timeline/defaults.ts49
-rw-r--r--apps/web/src/timeline/drag-utils.ts8
-rw-r--r--apps/web/src/timeline/element-snap-source.ts3
-rw-r--r--apps/web/src/timeline/element-utils.ts169
-rw-r--r--apps/web/src/timeline/group-move/build-group.ts6
-rw-r--r--apps/web/src/timeline/group-move/resolve-move.ts71
-rw-r--r--apps/web/src/timeline/group-move/snap.ts31
-rw-r--r--apps/web/src/timeline/group-move/types.ts7
-rw-r--r--apps/web/src/timeline/group-resize/compute-resize.ts274
-rw-r--r--apps/web/src/timeline/group-resize/types.ts27
-rw-r--r--apps/web/src/timeline/hooks/element/use-element-interaction.ts18
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-drag.ts21
-rw-r--r--apps/web/src/timeline/hooks/use-snap-indicator-position.ts24
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-drag-drop.ts14
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-playhead.ts26
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-resize.ts19
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-seek.ts29
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-zoom.ts30
-rw-r--r--apps/web/src/timeline/index.ts62
-rw-r--r--apps/web/src/timeline/placement/__tests__/resolve.test.ts92
-rw-r--r--apps/web/src/timeline/placement/main-track.ts9
-rw-r--r--apps/web/src/timeline/placement/resolve.ts3
-rw-r--r--apps/web/src/timeline/placement/types.ts7
-rw-r--r--apps/web/src/timeline/playhead-snap-source.ts3
-rw-r--r--apps/web/src/timeline/scenes.ts5
-rw-r--r--apps/web/src/timeline/snapping/resolve.ts3
-rw-r--r--apps/web/src/timeline/snapping/types.ts6
-rw-r--r--apps/web/src/timeline/types.ts94
-rw-r--r--apps/web/src/timeline/update-pipeline.ts25
-rw-r--r--apps/web/src/utils/geometry.ts10
-rw-r--r--apps/web/src/wasm/index.ts2
-rw-r--r--apps/web/src/wasm/media-time.ts232
-rw-r--r--apps/web/src/wasm/ticks.ts3
-rw-r--r--apps/web/tsconfig.json78
-rw-r--r--bun.lock1137
-rw-r--r--eslint.config.mjs96
-rw-r--r--eslint/rules/__tests__/prefer-object-params.test.mjs112
-rw-r--r--eslint/rules/prefer-object-params.mjs107
-rw-r--r--package.json23
-rw-r--r--rust/wasm/Cargo.toml2
285 files changed, 10664 insertions, 8343 deletions
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index f251603f..5edc56fb 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -168,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
2. Make your changes
3. Run the relevant checks for the area you touched:
- - Web changes: from `apps/web`, run `bun run lint` and `bunx biome format --write .`
+ - Web changes: from `apps/web`, run `bun run lint` and `bun run format`
- Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet
4. Commit your changes with a descriptive message
@@ -176,8 +176,8 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
## Code Style
-- We use Biome for code formatting and linting
-- Run `bunx biome format --write .` from the `apps/web` directory to format code
+- We use ESLint for linting and Prettier for formatting
+- Run `bun run format` from the `apps/web` directory to format code
- Run `bun run lint` from the `apps/web` directory to check for linting issues
- Follow the existing code patterns
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index c30aecc2..77d2c4f0 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,344 +1,342 @@
----
-applyTo: "**/*.{ts,tsx,js,jsx}"
----
-
-# Project Context
-
-Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
-
-## Key Principles
-
-- Zero configuration required
-- Subsecond performance
-- Maximum type safety
-- AI-friendly code generation
-
-## Before Writing Code
-
-1. Analyze existing patterns in the codebase
-2. Consider edge cases and error scenarios
-3. Follow the rules below strictly
-4. Validate accessibility requirements
-
-## Rules
-
-### Accessibility (a11y)
-
-- Don't use `accessKey` attribute on any HTML element.
-- Don't set `aria-hidden="true"` on focusable elements.
-- Don't add ARIA roles, states, and properties to elements that don't support them.
-- Don't use distracting elements like `<marquee>` or `<blink>`.
-- Only use the `scope` prop on `<th>` elements.
-- Don't assign non-interactive ARIA roles to interactive HTML elements.
-- Make sure label elements have text content and are associated with an input.
-- Don't assign interactive ARIA roles to non-interactive HTML elements.
-- Don't assign `tabIndex` to non-interactive HTML elements.
-- Don't use positive integers for `tabIndex` property.
-- Don't include "image", "picture", or "photo" in img alt prop.
-- Don't use explicit role property that's the same as the implicit/default role.
-- Make static elements with click handlers use a valid role attribute.
-- Always include a `title` element for SVG elements.
-- Give all elements requiring alt text meaningful information for screen readers.
-- Make sure anchors have content that's accessible to screen readers.
-- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
-- Include all required ARIA attributes for elements with ARIA roles.
-- Make sure ARIA properties are valid for the element's supported roles.
-- Always include a `type` attribute for button elements.
-- Make elements with interactive roles and handlers focusable.
-- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
-- Always include a `lang` attribute on the html element.
-- Always include a `title` attribute for iframe elements.
-- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
-- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
-- Include caption tracks for audio and video elements.
-- Use semantic elements instead of role attributes in JSX.
-- Make sure all anchors are valid and navigable.
-- Ensure all ARIA properties (`aria-*`) are valid.
-- Use valid, non-abstract ARIA roles for elements with ARIA roles.
-- Use valid ARIA state and property values.
-- Use valid values for the `autocomplete` attribute on input elements.
-- Use correct ISO language/country codes for the `lang` attribute.
-
-### Code Complexity and Quality
-
-- Don't use consecutive spaces in regular expression literals.
-- Don't use the `arguments` object.
-- Don't use primitive type aliases or misleading types.
-- Don't use the comma operator.
-- Don't use empty type parameters in type aliases and interfaces.
-- Don't write functions that exceed a given Cognitive Complexity score.
-- Don't nest describe() blocks too deeply in test files.
-- Don't use unnecessary boolean casts.
-- Don't use unnecessary callbacks with flatMap.
-- Use for...of statements instead of Array.forEach.
-- Don't create classes that only have static members (like a static namespace).
-- Don't use this and super in static contexts.
-- Don't use unnecessary catch clauses.
-- Don't use unnecessary constructors.
-- Don't use unnecessary continue statements.
-- Don't export empty modules that don't change anything.
-- Don't use unnecessary escape sequences in regular expression literals.
-- Don't use unnecessary fragments.
-- Don't use unnecessary labels.
-- Don't use unnecessary nested block statements.
-- Don't rename imports, exports, and destructured assignments to the same name.
-- Don't use unnecessary string or template literal concatenation.
-- Don't use String.raw in template literals when there are no escape sequences.
-- Don't use useless case statements in switch statements.
-- Don't use ternary operators when simpler alternatives exist.
-- Don't use useless `this` aliasing.
-- Don't use any or unknown as type constraints.
-- Don't initialize variables to undefined.
-- Don't use the void operators (they're not familiar).
-- Use arrow functions instead of function expressions.
-- Use Date.now() to get milliseconds since the Unix Epoch.
-- Use .flatMap() instead of map().flat() when possible.
-- Use literal property access instead of computed property access.
-- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
-- Use concise optional chaining instead of chained logical expressions.
-- Use regular expression literals instead of the RegExp constructor when possible.
-- Don't use number literal object member names that aren't base 10 or use underscore separators.
-- Remove redundant terms from logical expressions.
-- Use while loops instead of for loops when you don't need initializer and update expressions.
-- Don't pass children as props.
-- Don't reassign const variables.
-- Don't use constant expressions in conditions.
-- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
-- Don't return a value from a constructor.
-- Don't use empty character classes in regular expression literals.
-- Don't use empty destructuring patterns.
-- Don't call global object properties as functions.
-- Don't declare functions and vars that are accessible outside their block.
-- Make sure builtins are correctly instantiated.
-- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
-- Don't use variables and function parameters before they're declared.
-- Don't use 8 and 9 escape sequences in string literals.
-- Don't use literal numbers that lose precision.
-
-### React and JSX Best Practices
-
-- Don't use the return value of React.render.
-- Make sure all dependencies are correctly specified in React hooks.
-- Make sure all React hooks are called from the top level of component functions.
-- Don't forget key props in iterators and collection literals.
-- Don't destructure props inside JSX components in Solid projects.
-- Don't define React components inside other components.
-- Don't use event handlers on non-interactive elements.
-- Don't assign to React component props.
-- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
-- Don't use dangerous JSX props.
-- Don't use Array index in keys.
-- Don't insert comments as text nodes.
-- Don't assign JSX properties multiple times.
-- Don't add extra closing tags for components without children.
-- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
-- Watch out for possible "wrong" semicolons inside JSX elements.
-
-### Correctness and Safety
-
-- Don't assign a value to itself.
-- Don't return a value from a setter.
-- Don't compare expressions that modify string case with non-compliant values.
-- Don't use lexical declarations in switch clauses.
-- Don't use variables that haven't been declared in the document.
-- Don't write unreachable code.
-- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
-- Don't use control flow statements in finally blocks.
-- Don't use optional chaining where undefined values aren't allowed.
-- Don't have unused function parameters.
-- Don't have unused imports.
-- Don't have unused labels.
-- Don't have unused private class members.
-- Don't have unused variables.
-- Make sure void (self-closing) elements don't have children.
-- Don't return a value from a function with the return type 'void'
-- Use isNaN() when checking for NaN.
-- Make sure "for" loop update clauses move the counter in the right direction.
-- Make sure typeof expressions are compared to valid values.
-- Make sure generator functions contain yield.
-- Don't use await inside loops.
-- Don't use bitwise operators.
-- Don't use expressions where the operation doesn't change the value.
-- Make sure Promise-like statements are handled appropriately.
-- Don't use **dirname and **filename in the global scope.
-- Prevent import cycles.
-- Don't use configured elements.
-- Don't hardcode sensitive data like API keys and tokens.
-- Don't let variable declarations shadow variables from outer scopes.
-- Don't use the TypeScript directive @ts-ignore.
-- Prevent duplicate polyfills from Polyfill.io.
-- Don't use useless backreferences in regular expressions that always match empty strings.
-- Don't use unnecessary escapes in string literals.
-- Don't use useless undefined.
-- Make sure getters and setters for the same property are next to each other in class and object definitions.
-- Make sure object literals are declared consistently (defaults to explicit definitions).
-- Use static Response methods instead of new Response() constructor when possible.
-- Make sure switch-case statements are exhaustive.
-- Make sure the `preconnect` attribute is used when using Google Fonts.
-- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
-- Make sure iterable callbacks return consistent values.
-- Use `with { type: "json" }` for JSON module imports.
-- Use numeric separators in numeric literals.
-- Use object spread instead of `Object.assign()` when constructing new objects.
-- Always use the radix argument when using `parseInt()`.
-- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
-- Include a description parameter for `Symbol()`.
-- Don't use spread (`...`) syntax on accumulators.
-- Don't use the `delete` operator.
-- Don't access namespace imports dynamically.
-- Don't use namespace imports.
-- Declare regex literals at the top level.
-- Don't use `target="_blank"` without `rel="noopener"`.
-
-### TypeScript Best Practices
-
-- Don't use TypeScript enums.
-- Don't export imported variables.
-- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
-- Don't use TypeScript namespaces.
-- Don't use non-null assertions with the `!` postfix operator.
-- Don't use parameter properties in class constructors.
-- Don't use user-defined types.
-- Use `as const` instead of literal types and type annotations.
-- Use either `T[]` or `Array<T>` consistently.
-- Initialize each enum member value explicitly.
-- Use `export type` for types.
-- Use `import type` for types.
-- Make sure all enum members are literal values.
-- Don't use TypeScript const enum.
-- Don't declare empty interfaces.
-- Don't let variables evolve into any type through reassignments.
-- Don't use the any type.
-- Don't misuse the non-null assertion operator (!) in TypeScript files.
-- Don't use implicit any type on variable declarations.
-- Don't merge interfaces and classes unsafely.
-- Don't use overload signatures that aren't next to each other.
-- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
-
-### Style and Consistency
-
-- Don't use global `eval()`.
-- Don't use callbacks in asynchronous tests and hooks.
-- Don't use negation in `if` statements that have `else` clauses.
-- Don't use nested ternary expressions.
-- Don't reassign function parameters.
-- This rule lets you specify global variable names you don't want to use in your application.
-- Don't use specified modules when loaded by import or require.
-- Don't use constants whose value is the upper-case version of their name.
-- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
-- Don't use template literals if you don't need interpolation or special-character handling.
-- Don't use `else` blocks when the `if` block breaks early.
-- Don't use yoda expressions.
-- Don't use Array constructors.
-- Use `at()` instead of integer index access.
-- Follow curly brace conventions.
-- Use `else if` instead of nested `if` statements in `else` clauses.
-- Use single `if` statements instead of nested `if` clauses.
-- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
-- Use consistent accessibility modifiers on class properties and methods.
-- Use `const` declarations for variables that are only assigned once.
-- Put default function parameters and optional function parameters last.
-- Include a `default` clause in switch statements.
-- Use the `**` operator instead of `Math.pow`.
-- Use `for-of` loops when you need the index to extract an item from the iterated array.
-- Use `node:assert/strict` over `node:assert`.
-- Use the `node:` protocol for Node.js builtin modules.
-- Use Number properties instead of global ones.
-- Use assignment operator shorthand where possible.
-- Use function types instead of object types with call signatures.
-- Use template literals over string concatenation.
-- Use `new` when throwing an error.
-- Don't throw non-Error values.
-- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
-- Use standard constants instead of approximated literals.
-- Don't assign values in expressions.
-- Don't use async functions as Promise executors.
-- Don't reassign exceptions in catch clauses.
-- Don't reassign class members.
-- Don't compare against -0.
-- Don't use labeled statements that aren't loops.
-- Don't use void type outside of generic or return types.
-- Don't use console.
-- Don't use control characters and escape sequences that match control characters in regular expression literals.
-- Don't use debugger.
-- Don't assign directly to document.cookie.
-- Use `===` and `!==`.
-- Don't use duplicate case labels.
-- Don't use duplicate class members.
-- Don't use duplicate conditions in if-else-if chains.
-- Don't use two keys with the same name inside objects.
-- Don't use duplicate function parameter names.
-- Don't have duplicate hooks in describe blocks.
-- Don't use empty block statements and static blocks.
-- Don't let switch clauses fall through.
-- Don't reassign function declarations.
-- Don't allow assignments to native objects and read-only global variables.
-- Use Number.isFinite instead of global isFinite.
-- Use Number.isNaN instead of global isNaN.
-- Don't assign to imported bindings.
-- Don't use irregular whitespace characters.
-- Don't use labels that share a name with a variable.
-- Don't use characters made with multiple code points in character class syntax.
-- Make sure to use new and constructor properly.
-- Don't use shorthand assign when the variable appears on both sides.
-- Don't use octal escape sequences in string literals.
-- Don't use Object.prototype builtins directly.
-- Don't redeclare variables, functions, classes, and types in the same scope.
-- Don't have redundant "use strict".
-- Don't compare things where both sides are exactly the same.
-- Don't let identifiers shadow restricted names.
-- Don't use sparse arrays (arrays with holes).
-- Don't use template literal placeholder syntax in regular strings.
-- Don't use the then property.
-- Don't use unsafe negation.
-- Don't use var.
-- Don't use with statements in non-strict contexts.
-- Make sure async functions actually use await.
-- Make sure default clauses in switch statements come last.
-- Make sure to pass a message value when creating a built-in error.
-- Make sure get methods always return a value.
-- Use a recommended display strategy with Google Fonts.
-- Make sure for-in loops include an if statement.
-- Use Array.isArray() instead of instanceof Array.
-- Make sure to use the digits argument with Number#toFixed().
-- Make sure to use the "use strict" directive in script files.
-
-### Next.js Specific Rules
-
-- Don't use `<img>` elements in Next.js projects.
-- Don't use `<head>` elements in Next.js projects.
-- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
-- Don't use the next/head module in pages/\_document.js on Next.js projects.
-
-### Testing Best Practices
-
-- Don't use export or module.exports in test files.
-- Don't use focused tests.
-- Make sure the assertion function, like expect, is placed inside an it() function call.
-- Don't use disabled tests.
-
-## Common Tasks
-
-- `npx ultracite init` - Initialize Ultracite in your project
-- `npx ultracite format` - Format and fix code automatically
-- `npx ultracite lint` - Check for issues without fixing
-
-## Example: Error Handling
-
-```typescript
-// ✅ Good: Comprehensive error handling
-try {
- const result = await fetchData();
- return { success: true, data: result };
-} catch (error) {
- console.error("API call failed:", error);
- return { success: false, error: error.message };
-}
-
-// ❌ Bad: Swallowing errors
-try {
- return await fetchData();
-} catch (e) {
- console.log(e);
-}
-```
+---
+applyTo: "**/*.{ts,tsx,js,jsx}"
+---
+
+# Project Context
+
+Strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects, enforced via ESLint (linting) and Prettier (formatting).
+
+## Key Principles
+
+- Maximum type safety
+- AI-friendly code generation
+
+## Before Writing Code
+
+1. Analyze existing patterns in the codebase
+2. Consider edge cases and error scenarios
+3. Follow the rules below strictly
+4. Validate accessibility requirements
+
+## Rules
+
+### Accessibility (a11y)
+
+- Don't use `accessKey` attribute on any HTML element.
+- Don't set `aria-hidden="true"` on focusable elements.
+- Don't add ARIA roles, states, and properties to elements that don't support them.
+- Don't use distracting elements like `<marquee>` or `<blink>`.
+- Only use the `scope` prop on `<th>` elements.
+- Don't assign non-interactive ARIA roles to interactive HTML elements.
+- Make sure label elements have text content and are associated with an input.
+- Don't assign interactive ARIA roles to non-interactive HTML elements.
+- Don't assign `tabIndex` to non-interactive HTML elements.
+- Don't use positive integers for `tabIndex` property.
+- Don't include "image", "picture", or "photo" in img alt prop.
+- Don't use explicit role property that's the same as the implicit/default role.
+- Make static elements with click handlers use a valid role attribute.
+- Always include a `title` element for SVG elements.
+- Give all elements requiring alt text meaningful information for screen readers.
+- Make sure anchors have content that's accessible to screen readers.
+- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
+- Include all required ARIA attributes for elements with ARIA roles.
+- Make sure ARIA properties are valid for the element's supported roles.
+- Always include a `type` attribute for button elements.
+- Make elements with interactive roles and handlers focusable.
+- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
+- Always include a `lang` attribute on the html element.
+- Always include a `title` attribute for iframe elements.
+- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
+- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
+- Include caption tracks for audio and video elements.
+- Use semantic elements instead of role attributes in JSX.
+- Make sure all anchors are valid and navigable.
+- Ensure all ARIA properties (`aria-*`) are valid.
+- Use valid, non-abstract ARIA roles for elements with ARIA roles.
+- Use valid ARIA state and property values.
+- Use valid values for the `autocomplete` attribute on input elements.
+- Use correct ISO language/country codes for the `lang` attribute.
+
+### Code Complexity and Quality
+
+- Don't use consecutive spaces in regular expression literals.
+- Don't use the `arguments` object.
+- Don't use primitive type aliases or misleading types.
+- Don't use the comma operator.
+- Don't use empty type parameters in type aliases and interfaces.
+- Don't write functions that exceed a given Cognitive Complexity score.
+- Don't nest describe() blocks too deeply in test files.
+- Don't use unnecessary boolean casts.
+- Don't use unnecessary callbacks with flatMap.
+- Use for...of statements instead of Array.forEach.
+- Don't create classes that only have static members (like a static namespace).
+- Don't use this and super in static contexts.
+- Don't use unnecessary catch clauses.
+- Don't use unnecessary constructors.
+- Don't use unnecessary continue statements.
+- Don't export empty modules that don't change anything.
+- Don't use unnecessary escape sequences in regular expression literals.
+- Don't use unnecessary fragments.
+- Don't use unnecessary labels.
+- Don't use unnecessary nested block statements.
+- Don't rename imports, exports, and destructured assignments to the same name.
+- Don't use unnecessary string or template literal concatenation.
+- Don't use String.raw in template literals when there are no escape sequences.
+- Don't use useless case statements in switch statements.
+- Don't use ternary operators when simpler alternatives exist.
+- Don't use useless `this` aliasing.
+- Don't use any or unknown as type constraints.
+- Don't initialize variables to undefined.
+- Don't use the void operators (they're not familiar).
+- Use arrow functions instead of function expressions.
+- Use Date.now() to get milliseconds since the Unix Epoch.
+- Use .flatMap() instead of map().flat() when possible.
+- Use literal property access instead of computed property access.
+- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
+- Use concise optional chaining instead of chained logical expressions.
+- Use regular expression literals instead of the RegExp constructor when possible.
+- Don't use number literal object member names that aren't base 10 or use underscore separators.
+- Remove redundant terms from logical expressions.
+- Use while loops instead of for loops when you don't need initializer and update expressions.
+- Don't pass children as props.
+- Don't reassign const variables.
+- Don't use constant expressions in conditions.
+- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
+- Don't return a value from a constructor.
+- Don't use empty character classes in regular expression literals.
+- Don't use empty destructuring patterns.
+- Don't call global object properties as functions.
+- Don't declare functions and vars that are accessible outside their block.
+- Make sure builtins are correctly instantiated.
+- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
+- Don't use variables and function parameters before they're declared.
+- Don't use 8 and 9 escape sequences in string literals.
+- Don't use literal numbers that lose precision.
+
+### React and JSX Best Practices
+
+- Don't use the return value of React.render.
+- Make sure all dependencies are correctly specified in React hooks.
+- Make sure all React hooks are called from the top level of component functions.
+- Don't forget key props in iterators and collection literals.
+- Don't destructure props inside JSX components in Solid projects.
+- Don't define React components inside other components.
+- Don't use event handlers on non-interactive elements.
+- Don't assign to React component props.
+- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
+- Don't use dangerous JSX props.
+- Don't use Array index in keys.
+- Don't insert comments as text nodes.
+- Don't assign JSX properties multiple times.
+- Don't add extra closing tags for components without children.
+- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
+- Watch out for possible "wrong" semicolons inside JSX elements.
+
+### Correctness and Safety
+
+- Don't assign a value to itself.
+- Don't return a value from a setter.
+- Don't compare expressions that modify string case with non-compliant values.
+- Don't use lexical declarations in switch clauses.
+- Don't use variables that haven't been declared in the document.
+- Don't write unreachable code.
+- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
+- Don't use control flow statements in finally blocks.
+- Don't use optional chaining where undefined values aren't allowed.
+- Don't have unused function parameters.
+- Don't have unused imports.
+- Don't have unused labels.
+- Don't have unused private class members.
+- Don't have unused variables.
+- Make sure void (self-closing) elements don't have children.
+- Don't return a value from a function with the return type 'void'
+- Use isNaN() when checking for NaN.
+- Make sure "for" loop update clauses move the counter in the right direction.
+- Make sure typeof expressions are compared to valid values.
+- Make sure generator functions contain yield.
+- Don't use await inside loops.
+- Don't use bitwise operators.
+- Don't use expressions where the operation doesn't change the value.
+- Make sure Promise-like statements are handled appropriately.
+- Don't use **dirname and **filename in the global scope.
+- Prevent import cycles.
+- Don't use configured elements.
+- Don't hardcode sensitive data like API keys and tokens.
+- Don't let variable declarations shadow variables from outer scopes.
+- Don't use the TypeScript directive @ts-ignore.
+- Prevent duplicate polyfills from Polyfill.io.
+- Don't use useless backreferences in regular expressions that always match empty strings.
+- Don't use unnecessary escapes in string literals.
+- Don't use useless undefined.
+- Make sure getters and setters for the same property are next to each other in class and object definitions.
+- Make sure object literals are declared consistently (defaults to explicit definitions).
+- Use static Response methods instead of new Response() constructor when possible.
+- Make sure switch-case statements are exhaustive.
+- Make sure the `preconnect` attribute is used when using Google Fonts.
+- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
+- Make sure iterable callbacks return consistent values.
+- Use `with { type: "json" }` for JSON module imports.
+- Use numeric separators in numeric literals.
+- Use object spread instead of `Object.assign()` when constructing new objects.
+- Always use the radix argument when using `parseInt()`.
+- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
+- Include a description parameter for `Symbol()`.
+- Don't use spread (`...`) syntax on accumulators.
+- Don't use the `delete` operator.
+- Don't access namespace imports dynamically.
+- Don't use namespace imports.
+- Declare regex literals at the top level.
+- Don't use `target="_blank"` without `rel="noopener"`.
+
+### TypeScript Best Practices
+
+- Don't use TypeScript enums.
+- Don't export imported variables.
+- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
+- Don't use TypeScript namespaces.
+- Don't use non-null assertions with the `!` postfix operator.
+- Don't use parameter properties in class constructors.
+- Don't use user-defined types.
+- Use `as const` instead of literal types and type annotations.
+- Use either `T[]` or `Array<T>` consistently.
+- Initialize each enum member value explicitly.
+- Use `export type` for types.
+- Use `import type` for types.
+- Make sure all enum members are literal values.
+- Don't use TypeScript const enum.
+- Don't declare empty interfaces.
+- Don't let variables evolve into any type through reassignments.
+- Don't use the any type.
+- Don't misuse the non-null assertion operator (!) in TypeScript files.
+- Don't use implicit any type on variable declarations.
+- Don't merge interfaces and classes unsafely.
+- Don't use overload signatures that aren't next to each other.
+- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
+
+### Style and Consistency
+
+- Don't use global `eval()`.
+- Don't use callbacks in asynchronous tests and hooks.
+- Don't use negation in `if` statements that have `else` clauses.
+- Don't use nested ternary expressions.
+- Don't reassign function parameters.
+- This rule lets you specify global variable names you don't want to use in your application.
+- Don't use specified modules when loaded by import or require.
+- Don't use constants whose value is the upper-case version of their name.
+- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
+- Don't use template literals if you don't need interpolation or special-character handling.
+- Don't use `else` blocks when the `if` block breaks early.
+- Don't use yoda expressions.
+- Don't use Array constructors.
+- Use `at()` instead of integer index access.
+- Follow curly brace conventions.
+- Use `else if` instead of nested `if` statements in `else` clauses.
+- Use single `if` statements instead of nested `if` clauses.
+- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
+- Use consistent accessibility modifiers on class properties and methods.
+- Use `const` declarations for variables that are only assigned once.
+- Put default function parameters and optional function parameters last.
+- Include a `default` clause in switch statements.
+- Use the `**` operator instead of `Math.pow`.
+- Use `for-of` loops when you need the index to extract an item from the iterated array.
+- Use `node:assert/strict` over `node:assert`.
+- Use the `node:` protocol for Node.js builtin modules.
+- Use Number properties instead of global ones.
+- Use assignment operator shorthand where possible.
+- Use function types instead of object types with call signatures.
+- Use template literals over string concatenation.
+- Use `new` when throwing an error.
+- Don't throw non-Error values.
+- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
+- Use standard constants instead of approximated literals.
+- Don't assign values in expressions.
+- Don't use async functions as Promise executors.
+- Don't reassign exceptions in catch clauses.
+- Don't reassign class members.
+- Don't compare against -0.
+- Don't use labeled statements that aren't loops.
+- Don't use void type outside of generic or return types.
+- Don't use console.
+- Don't use control characters and escape sequences that match control characters in regular expression literals.
+- Don't use debugger.
+- Don't assign directly to document.cookie.
+- Use `===` and `!==`.
+- Don't use duplicate case labels.
+- Don't use duplicate class members.
+- Don't use duplicate conditions in if-else-if chains.
+- Don't use two keys with the same name inside objects.
+- Don't use duplicate function parameter names.
+- Don't have duplicate hooks in describe blocks.
+- Don't use empty block statements and static blocks.
+- Don't let switch clauses fall through.
+- Don't reassign function declarations.
+- Don't allow assignments to native objects and read-only global variables.
+- Use Number.isFinite instead of global isFinite.
+- Use Number.isNaN instead of global isNaN.
+- Don't assign to imported bindings.
+- Don't use irregular whitespace characters.
+- Don't use labels that share a name with a variable.
+- Don't use characters made with multiple code points in character class syntax.
+- Make sure to use new and constructor properly.
+- Don't use shorthand assign when the variable appears on both sides.
+- Don't use octal escape sequences in string literals.
+- Don't use Object.prototype builtins directly.
+- Don't redeclare variables, functions, classes, and types in the same scope.
+- Don't have redundant "use strict".
+- Don't compare things where both sides are exactly the same.
+- Don't let identifiers shadow restricted names.
+- Don't use sparse arrays (arrays with holes).
+- Don't use template literal placeholder syntax in regular strings.
+- Don't use the then property.
+- Don't use unsafe negation.
+- Don't use var.
+- Don't use with statements in non-strict contexts.
+- Make sure async functions actually use await.
+- Make sure default clauses in switch statements come last.
+- Make sure to pass a message value when creating a built-in error.
+- Make sure get methods always return a value.
+- Use a recommended display strategy with Google Fonts.
+- Make sure for-in loops include an if statement.
+- Use Array.isArray() instead of instanceof Array.
+- Make sure to use the digits argument with Number#toFixed().
+- Make sure to use the "use strict" directive in script files.
+
+### Next.js Specific Rules
+
+- Don't use `<img>` elements in Next.js projects.
+- Don't use `<head>` elements in Next.js projects.
+- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
+- Don't use the next/head module in pages/\_document.js on Next.js projects.
+
+### Testing Best Practices
+
+- Don't use export or module.exports in test files.
+- Don't use focused tests.
+- Make sure the assertion function, like expect, is placed inside an it() function call.
+- Don't use disabled tests.
+
+## Common Tasks
+
+- `npx ultracite init` - Initialize Ultracite in your project
+- `npx ultracite format` - Format and fix code automatically
+- `npx ultracite lint` - Check for issues without fixing
+
+## Example: Error Handling
+
+```typescript
+// ✅ Good: Comprehensive error handling
+try {
+ const result = await fetchData();
+ return { success: true, data: result };
+} catch (error) {
+ console.error("API call failed:", error);
+ return { success: false, error: error.message };
+}
+
+// ❌ Bad: Swallowing errors
+try {
+ return await fetchData();
+} catch (e) {
+ console.log(e);
+}
+```
diff --git a/.gitignore b/.gitignore
index 2909d741..f5d4d26e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,6 @@ bun.lockb
target/
-.cargo-tools/ \ No newline at end of file
+.cargo-tools/
+
+rust-migration-notes.md \ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..13be308b
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,6 @@
+.next
+node_modules
+dist
+build
+target
+rust/wasm/pkg
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 00000000..c9590876
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,3 @@
+{
+ "useTabs": true
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 187722fb..2fc093b5 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -7,14 +7,7 @@
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
- "source.fixAll.biome": "explicit",
- "source.organizeImports.biome": "explicit"
+ "source.fixAll.eslint": "explicit"
},
- "emmet.showExpandedAbbreviation": "never",
- "[typescriptreact]": {
- "editor.defaultFormatter": "biomejs.biome"
- },
- "[typescript]": {
- "editor.defaultFormatter": "biomejs.biome"
- }
+ "emmet.showExpandedAbbreviation": "never"
}
diff --git a/AGENTS.md b/AGENTS.md
index d326ac5d..cd59a85c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,17 +21,3 @@ Each app is a frontend that calls into Rust. Logic is never duplicated between a
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
-### TypeScript
-
-Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter:
-
-```tsx
-// ❌ meaning depends on order; the shape can't evolve without touching every caller
-function formatTime(seconds: number, fps: number) { ... }
-
-// ✅ each argument names itself; fields can be added, reordered, or made optional freely
-function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
-```
-
-The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply.
-
diff --git a/Cargo.lock b/Cargo.lock
index f3bd3c00..04c00a6c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3820,7 +3820,7 @@ dependencies = [
[[package]]
name = "opencut-wasm"
-version = "0.2.9"
+version = "0.2.10"
dependencies = [
"bridge",
"compositor",
diff --git a/README.md b/README.md
index 3cb67abf..79bcf310 100644
--- a/README.md
+++ b/README.md
@@ -1,167 +1,140 @@
-<table width="100%">
- <tr>
- <td align="left" width="120">
- <img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" />
- </td>
- <td align="right">
- <h1>OpenCut</h1>
- <h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
- </td>
- </tr>
-</table>
-
-## Sponsors
-
-Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
-
-<a href="https://vercel.com/oss">
- <img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
-</a>
-
-<a href="https://fal.ai">
- <img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
-</a>
-
-## Why?
-
-- **Privacy**: Your videos stay on your device
-- **Free features**: Most basic CapCut features are now paywalled
-- **Simple**: People want editors that are easy to use - CapCut proved that
-
-## Project Structure
-
-- `apps/web/`: Next.js web application
-- `apps/desktop/`: Native desktop app built with GPUI (in progress)
-- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
-- `docs/`: Architecture and subsystem documentation
-
-## Getting Started
-
-### Prerequisites
-
-- [Bun](https://bun.sh/docs/installation)
-- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
-
-> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
-
-### Setup
-
-1. Fork and clone the repository
-
-2. Copy the environment file:
-
- ```bash
- # Unix/Linux/Mac
- cp apps/web/.env.example apps/web/.env.local
-
- # Windows PowerShell
- Copy-Item apps/web/.env.example apps/web/.env.local
- ```
-
-3. Start the database and Redis:
-
- ```bash
- docker compose up -d db redis serverless-redis-http
- ```
-
-4. Install dependencies and start the dev server:
-
- ```bash
- bun install
- bun dev:web
- ```
-
-The application will be available at [http://localhost:3000](http://localhost:3000).
-
-The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
-
-### Desktop setup
-
-Desktop is opt-in. If you're only working on the web app, skip this entirely.
-
-If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies.
-
-### Local WASM development
-
-Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
-
-**Prerequisites** — install these once before anything else:
-
-```bash
-# Rust toolchain
-curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
-
-# build the WASM package
-cargo install wasm-pack
-
-# reruns the build on file changes, used by bun dev:wasm
-cargo install cargo-watch
-```
-
-1. Build the package once from the repo root:
-
- ```bash
- bun run build:wasm
- ```
-
-2. Register the generated package for linking:
-
- ```bash
- cd rust/wasm/pkg
- bun link
- ```
-
-3. Link `apps/web` to the local package:
-
- ```bash
- cd apps/web
- bun link opencut-wasm
- ```
-
-4. Rebuild on changes while you work:
-
- ```bash
- bun dev:wasm
- ```
-
-To switch `apps/web` back to the published package, run:
-
-```bash
-cd apps/web
-bun add opencut-wasm
-```
-
-### Self-Hosting with Docker
-
-To run everything (including a production build of the app) in Docker:
-
-```bash
-docker compose up -d
-```
-
-The app will be available at [http://localhost:3100](http://localhost:3100).
-
-## Contributing
-
-We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
-
-**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
-
-**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
-
-See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
-
-**Quick start for contributors:**
-
-- Fork the repo and clone locally
-- Follow the setup instructions in CONTRIBUTING.md
-- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
-- Create a feature branch and submit a PR
-
-## License
-
-[MIT LICENSE](LICENSE)
-
----
-
-![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
+
+| | |
+| --- | ---------------------------------------------------------------------- |
+| | OpenCutA free, open-source video editor for web, desktop, and mobile. |
+
+
+## Sponsors
+
+Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
+
+## Why?
+
+- **Privacy**: Your videos stay on your device
+- **Free features**: Most basic CapCut features are now paywalled
+- **Simple**: People want editors that are easy to use - CapCut proved that
+
+## Project Structure
+
+- `apps/web/`: Next.js web application
+- `apps/desktop/`: Native desktop app built with GPUI (in progress)
+- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
+- `docs/`: Architecture and subsystem documentation
+
+## Getting Started
+
+### Prerequisites
+
+- [Bun](https://bun.sh/docs/installation)
+- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
+
+> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
+
+### Setup
+
+1. Fork and clone the repository
+2. Copy the environment file:
+ ```bash
+ # Unix/Linux/Mac
+ cp apps/web/.env.example apps/web/.env.local
+
+ # Windows PowerShell
+ Copy-Item apps/web/.env.example apps/web/.env.local
+ ```
+3. Start the database and Redis:
+ ```bash
+ docker compose up -d db redis serverless-redis-http
+ ```
+4. Install dependencies and start the dev server:
+ ```bash
+ bun install
+ bun dev:web
+ ```
+
+The application will be available at [http://localhost:3000](http://localhost:3000).
+
+The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
+
+### Desktop setup
+
+Desktop is opt-in. If you're only working on the web app, skip this entirely.
+
+If you want to get ready for `apps/desktop`, see `[apps/desktop/README.md](apps/desktop/README.md)`. It's a two-step setup: Rust toolchain first, then desktop native dependencies.
+
+### Local WASM development
+
+Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
+
+**Prerequisites** — install these once before anything else:
+
+```bash
+# Rust toolchain
+curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
+
+# build the WASM package
+cargo install wasm-pack
+
+# reruns the build on file changes, used by bun dev:wasm
+cargo install cargo-watch
+```
+
+1. Build the package once from the repo root:
+ ```bash
+ bun run build:wasm
+ ```
+2. Register the generated package for linking:
+ ```bash
+ cd rust/wasm/pkg
+ bun link
+ ```
+3. Link `apps/web` to the local package:
+ ```bash
+ cd apps/web
+ bun link opencut-wasm
+ ```
+4. Rebuild on changes while you work:
+ ```bash
+ bun dev:wasm
+ ```
+
+To switch `apps/web` back to the published package, run:
+
+```bash
+cd apps/web
+bun add opencut-wasm
+```
+
+### Self-Hosting with Docker
+
+To run everything (including a production build of the app) in Docker:
+
+```bash
+docker compose up -d
+```
+
+The app will be available at [http://localhost:3100](http://localhost:3100).
+
+## Contributing
+
+We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
+
+**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
+
+**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
+
+See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
+
+**Quick start for contributors:**
+
+- Fork the repo and clone locally
+- Follow the setup instructions in CONTRIBUTING.md
+- Working on `apps/desktop`? See `[apps/desktop/README.md](apps/desktop/README.md)` for setup
+- Create a feature branch and submit a PR
+
+## License
+
+[MIT LICENSE](LICENSE)
+
+---
+
+Star History Chart \ No newline at end of file
diff --git a/apps/web/package.json b/apps/web/package.json
index 0034ebad..90cd9fb5 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,9 +9,9 @@
"start": "next start",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
- "lint": "biome check src/",
- "lint:fix": "biome check src/ --write",
- "format": "biome format src/ --write",
+ "lint": "eslint src --ext .ts,.tsx",
+ "lint:fix": "eslint src --ext .ts,.tsx --fix",
+ "format": "prettier src --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
@@ -52,7 +52,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
- "opencut-wasm": "^0.2.9",
+ "opencut-wasm": "^0.2.10",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
diff --git a/apps/web/src/actions/components/shortcuts-dialog.tsx b/apps/web/src/actions/components/shortcuts-dialog.tsx
index cd9c0242..7f7d8a87 100644
--- a/apps/web/src/actions/components/shortcuts-dialog.tsx
+++ b/apps/web/src/actions/components/shortcuts-dialog.tsx
@@ -51,10 +51,10 @@ export function ShortcutsDialog({
const keyString = getKeybindingString(e);
if (keyString) {
- const conflict = validateKeybinding(
- keyString,
- recordingShortcut.action,
- );
+ const conflict = validateKeybinding({
+ key: keyString,
+ action: recordingShortcut.action,
+ });
if (conflict) {
toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
@@ -68,7 +68,10 @@ export function ShortcutsDialog({
removeKeybinding(key);
}
- updateKeybinding(keyString, recordingShortcut.action);
+ updateKeybinding({
+ key: keyString,
+ action: recordingShortcut.action,
+ });
setIsRecording(false);
setRecordingShortcut(null);
diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts
index 6f0ba700..986510ec 100644
--- a/apps/web/src/actions/definitions.ts
+++ b/apps/web/src/actions/definitions.ts
@@ -1,7 +1,4 @@
-import type {
- KeybindingConfig,
- ShortcutKey,
-} from "@/actions/keybinding";
+import type { ShortcutKey } from "@/actions/keybinding";
import type { TActionWithOptionalArgs } from "./types";
export type TActionCategory =
@@ -155,33 +152,36 @@ export const ACTIONS = {
export type TAction = keyof typeof ACTIONS;
-const ACTION_DEFAULT_SHORTCUTS = {
- "toggle-play": ["space", "k"],
- "seek-forward": ["l"],
- "seek-backward": ["j"],
- "frame-step-forward": ["right"],
- "frame-step-backward": ["left"],
- "jump-forward": ["shift+right"],
- "jump-backward": ["shift+left"],
- "goto-start": ["home", "enter"],
- "goto-end": ["end"],
- split: ["s"],
- "split-left": ["q"],
- "split-right": ["w"],
- "delete-selected": ["backspace", "delete"],
- "copy-selected": ["ctrl+c"],
- "paste-copied": ["ctrl+v"],
- "toggle-snapping": ["n"],
- "select-all": ["ctrl+a"],
- "cancel-interaction": ["escape"],
- "duplicate-selected": ["ctrl+d"],
- undo: ["ctrl+z"],
- redo: ["ctrl+shift+z", "ctrl+y"],
-} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
+const ACTION_DEFAULT_SHORTCUTS = [
+ ["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 ReadonlyArray<
+ readonly [TActionWithOptionalArgs, readonly ShortcutKey[]]
+>;
-const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
- Record<TAction, readonly ShortcutKey[]>
-> = ACTION_DEFAULT_SHORTCUTS;
+const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map<
+ TAction,
+ readonly ShortcutKey[]
+>(ACTION_DEFAULT_SHORTCUTS);
export function getActionDefinition({
action,
@@ -190,18 +190,19 @@ export function getActionDefinition({
}): TActionDefinition {
return {
...ACTIONS[action],
- defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
+ defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action),
};
}
-export function getDefaultShortcuts(): KeybindingConfig {
- const shortcuts: KeybindingConfig = {};
+export function getDefaultShortcuts(): Map<
+ ShortcutKey,
+ TActionWithOptionalArgs
+> {
+ const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>();
- for (const [action, defaultShortcuts] of Object.entries(
- ACTION_DEFAULT_SHORTCUTS,
- ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
+ for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) {
for (const shortcut of defaultShortcuts) {
- shortcuts[shortcut] = action;
+ shortcuts.set(shortcut, action);
}
}
diff --git a/apps/web/src/actions/keybinding.ts b/apps/web/src/actions/keybinding.ts
index 65ef4538..aeec2fd6 100644
--- a/apps/web/src/actions/keybinding.ts
+++ b/apps/web/src/actions/keybinding.ts
@@ -1,79 +1,43 @@
-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;
-};
+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";
+
+const KEYS = [
+ "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",
+] as const;
+
+export type Key = (typeof KEYS)[number];
+
+const KEY_SET: ReadonlySet<string> = new Set(KEYS);
+
+export function isKey(value: string): value is Key {
+ return KEY_SET.has(value);
+}
+
+export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
+// Singular keybindings (these will be disabled when an input-ish area has been focused)
+export type SingleCharacterShortcutKey = `${Key}`;
+
+export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
+
+export type KeybindingConfig = {
+ [key in ShortcutKey]?: TActionWithOptionalArgs;
+};
diff --git a/apps/web/src/actions/keybindings-store.ts b/apps/web/src/actions/keybindings-store.ts
index c0a1a5e2..8f344487 100644
--- a/apps/web/src/actions/keybindings-store.ts
+++ b/apps/web/src/actions/keybindings-store.ts
@@ -6,11 +6,15 @@ import type { TActionWithOptionalArgs } from "@/actions";
import { getDefaultShortcuts } from "@/actions";
import { isTypableDOMElement } from "@/utils/browser";
import { isAppleDevice } from "@/utils/platform";
-import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
+import type {
+ Key,
+ KeybindingConfig,
+ ModifierKeys,
+ ShortcutKey,
+} from "@/actions/keybinding";
+import { isKey } from "@/actions/keybinding";
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
-const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
-
export interface KeybindingConflict {
key: ShortcutKey;
existingAction: TActionWithOptionalArgs;
@@ -18,38 +22,57 @@ export interface KeybindingConflict {
}
interface KeybindingsState {
- keybindings: KeybindingConfig;
+ keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
isCustomized: boolean;
overlayDepth: number;
openOverlayIds: string[];
isLoadingProject: boolean;
isRecording: boolean;
- updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void;
+ updateKeybinding: (params: {
+ key: ShortcutKey;
+ action: TActionWithOptionalArgs;
+ }) => void;
removeKeybinding: (key: ShortcutKey) => void;
resetToDefaults: () => void;
importKeybindings: (config: KeybindingConfig) => void;
- exportKeybindings: () => KeybindingConfig;
+ exportKeybindings: () => Record<string, TActionWithOptionalArgs>;
openOverlay: (overlayId: string) => void;
closeOverlay: (overlayId: string) => void;
setLoadingProject: (loading: boolean) => void;
setIsRecording: (isRecording: boolean) => void;
- validateKeybinding: (
- key: ShortcutKey,
- action: TActionWithOptionalArgs,
- ) => KeybindingConflict | null;
+ validateKeybinding: (params: {
+ key: ShortcutKey;
+ action: TActionWithOptionalArgs;
+ }) => KeybindingConflict | null;
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
}
+type PersistedState = {
+ keybindings: Record<string, TActionWithOptionalArgs>;
+ isCustomized: boolean;
+};
+
function isDOMElement(element: EventTarget | null): element is HTMLElement {
return element instanceof HTMLElement;
}
+function isPersistedState(value: unknown): value is PersistedState {
+ if (!value || typeof value !== "object") return false;
+ if (!("keybindings" in value) || !("isCustomized" in value)) return false;
+ const { keybindings, isCustomized } = value;
+ return (
+ typeof keybindings === "object" &&
+ keybindings !== null &&
+ typeof isCustomized === "boolean"
+ );
+}
+
export const useKeybindingsStore = create<KeybindingsState>()(
persist(
(set, get) => ({
- keybindings: { ...defaultKeybindings },
+ keybindings: getDefaultShortcuts(),
isCustomized: false,
overlayDepth: 0,
openOverlayIds: [],
@@ -61,10 +84,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
const openOverlayIds = s.openOverlayIds.includes(overlayId)
? s.openOverlayIds
: [...s.openOverlayIds, overlayId];
- const nextOverlayDepth = openOverlayIds.length;
return {
openOverlayIds,
- overlayDepth: nextOverlayDepth,
+ overlayDepth: openOverlayIds.length,
};
}),
closeOverlay: (overlayId) =>
@@ -72,35 +94,32 @@ export const useKeybindingsStore = create<KeybindingsState>()(
const openOverlayIds = s.openOverlayIds.filter(
(id) => id !== overlayId,
);
- const nextOverlayDepth = openOverlayIds.length;
return {
openOverlayIds,
- overlayDepth: nextOverlayDepth,
+ overlayDepth: openOverlayIds.length,
};
}),
setLoadingProject: (loading) => {
set({ isLoadingProject: loading });
},
- updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => {
+ updateKeybinding: ({ key, action }) => {
set((state) => {
- const newKeybindings = { ...state.keybindings };
- newKeybindings[key] = action;
-
+ const next = new Map(state.keybindings);
+ next.set(key, action);
return {
- keybindings: newKeybindings,
+ keybindings: next,
isCustomized: true,
};
});
},
- removeKeybinding: (key: ShortcutKey) => {
+ removeKeybinding: (key) => {
set((state) => {
- const newKeybindings = { ...state.keybindings };
- delete newKeybindings[key];
-
+ const next = new Map(state.keybindings);
+ next.delete(key);
return {
- keybindings: newKeybindings,
+ keybindings: next,
isCustomized: true,
};
});
@@ -108,34 +127,35 @@ export const useKeybindingsStore = create<KeybindingsState>()(
resetToDefaults: () => {
set({
- keybindings: { ...defaultKeybindings },
+ keybindings: getDefaultShortcuts(),
isCustomized: false,
});
},
- importKeybindings: (config: KeybindingConfig) => {
- for (const [key] of Object.entries(config)) {
+ importKeybindings: (config) => {
+ const next = new Map<ShortcutKey, TActionWithOptionalArgs>();
+ for (const [key, action] of Object.entries(config)) {
if (typeof key !== "string" || key.length === 0) {
throw new Error(`Invalid key format: ${key}`);
}
+ if (action !== undefined) {
+ // Public type's keys are `ShortcutKey`; trust the caller's typing.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ next.set(key as ShortcutKey, action);
+ }
}
set({
- keybindings: { ...config },
+ keybindings: next,
isCustomized: true,
});
},
exportKeybindings: () => {
- return get().keybindings;
+ return Object.fromEntries(get().keybindings);
},
- validateKeybinding: (
- key: ShortcutKey,
- action: TActionWithOptionalArgs,
- ) => {
- const { keybindings } = get();
- const existingAction = keybindings[key];
-
+ validateKeybinding: ({ key, action }) => {
+ const existingAction = get().keybindings.get(key);
if (existingAction && existingAction !== action) {
return {
key,
@@ -143,33 +163,45 @@ export const useKeybindingsStore = create<KeybindingsState>()(
newAction: action,
};
}
-
return null;
},
- setIsRecording: (isRecording: boolean) => {
+ setIsRecording: (isRecording) => {
set({ isRecording });
},
- getKeybindingsForAction: (action: TActionWithOptionalArgs) => {
- const { keybindings } = get();
- return Object.keys(keybindings).filter(
- (key) => keybindings[key as ShortcutKey] === action,
- ) as ShortcutKey[];
+ getKeybindingsForAction: (action) => {
+ const result: ShortcutKey[] = [];
+ for (const [key, mapped] of get().keybindings) {
+ if (mapped === action) result.push(key);
+ }
+ return result;
},
- getKeybindingString: (ev: KeyboardEvent) => {
- return generateKeybindingString(ev) as ShortcutKey | null;
- },
+ getKeybindingString: (ev) => generateKeybindingString(ev),
}),
{
name: "opencut-keybindings",
version: CURRENT_VERSION,
- partialize: (state) => ({
- keybindings: state.keybindings,
+ partialize: (state): PersistedState => ({
+ keybindings: Object.fromEntries(state.keybindings),
isCustomized: state.isCustomized,
}),
migrate: (persisted, version) =>
runMigrations({ state: persisted, fromVersion: version }),
+ merge: (persisted, current) => {
+ if (!isPersistedState(persisted)) return current;
+ const entries = Object.entries(persisted.keybindings);
+ // Persistence boundary: keys are normalized by the migration chain.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const typedEntries = entries as Array<
+ [ShortcutKey, TActionWithOptionalArgs]
+ >;
+ return {
+ ...current,
+ keybindings: new Map(typedEntries),
+ isCustomized: persisted.isCustomized,
+ };
+ },
},
),
);
@@ -184,68 +216,59 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
if (
modifierKey === "shift" &&
isDOMElement(target) &&
- isTypableDOMElement({ element: target as HTMLElement })
+ isTypableDOMElement({ element: target })
) {
return null;
}
- return `${modifierKey}+${key}` as ShortcutKey;
+ return `${modifierKey}+${key}`;
}
- if (
- isDOMElement(target) &&
- isTypableDOMElement({ element: target as HTMLElement })
- )
+ if (isDOMElement(target) && isTypableDOMElement({ element: target })) {
return null;
+ }
- return `${key}` as ShortcutKey;
+ return key;
}
-function getPressedKey(ev: KeyboardEvent): string | null {
- const key = (ev.key ?? "").toLowerCase();
+function getPressedKey(ev: KeyboardEvent): Key | null {
+ const raw = (ev.key ?? "").toLowerCase();
const code = ev.code ?? "";
- if (code === "Space" || key === " " || key === "spacebar" || key === "space")
+ if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space")
return "space";
- if (key.startsWith("arrow")) return key.slice(5);
-
- if (key === "escape") return "escape";
- if (key === "tab") return "tab";
- if (key === "home") return "home";
- if (key === "end") return "end";
- if (key === "delete") return "delete";
- if (key === "backspace") return "backspace";
+ if (raw === "arrowup") return "up";
+ if (raw === "arrowdown") return "down";
+ if (raw === "arrowleft") return "left";
+ if (raw === "arrowright") return "right";
if (code.startsWith("Key")) {
const letter = code.slice(3).toLowerCase();
- if (letter.length === 1 && letter >= "a" && letter <= "z") return letter;
+ if (isKey(letter)) return letter;
}
- // Use physical key position for AZERTY and other non-QWERTY layouts
+ // Use physical key position for AZERTY and other non-QWERTY layouts.
if (code.startsWith("Digit")) {
const digit = code.slice(5);
- if (digit.length === 1 && digit >= "0" && digit <= "9") return digit;
+ if (isKey(digit)) return digit;
}
- const isDigit = key.length === 1 && key >= "0" && key <= "9";
- if (isDigit) return key;
-
- if (key === "/" || key === "." || key === "enter") return key;
-
+ if (isKey(raw)) return raw;
return null;
}
-function getActiveModifier(ev: KeyboardEvent): string | null {
- const modifierKeys = {
- ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
- alt: ev.altKey,
- shift: ev.shiftKey,
- };
+function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null {
+ const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey;
+ const alt = ev.altKey;
+ const shift = ev.shiftKey;
- const activeModifier = Object.keys(modifierKeys)
- .filter((key) => modifierKeys[key as keyof typeof modifierKeys])
- .join("+");
-
- return activeModifier === "" ? null : activeModifier;
+ if (ctrl && alt && shift) return "ctrl+alt+shift";
+ if (ctrl && alt) return "ctrl+alt";
+ if (ctrl && shift) return "ctrl+shift";
+ if (alt && shift) return "alt+shift";
+ if (ctrl) return "ctrl";
+ if (alt) return "alt";
+ if (shift) return "shift";
+ return null;
}
diff --git a/apps/web/src/actions/keybindings/__tests__/persistence.test.ts b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts
new file mode 100644
index 00000000..bcccc6a4
--- /dev/null
+++ b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts
@@ -0,0 +1,144 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ mock,
+ test,
+} from "bun:test";
+import {
+ decodePersistedKeybindingsState,
+ migratePersistedKeybindingsState,
+ parseImportedKeybindings,
+ serializeKeybindingsState,
+} from "../persistence";
+
+describe("keybinding persistence", () => {
+ let warnSpy: ReturnType<typeof mock>;
+ let originalWarn: typeof console.warn;
+
+ beforeEach(() => {
+ originalWarn = console.warn;
+ warnSpy = mock(() => {});
+ console.warn = warnSpy;
+ });
+
+ afterEach(() => {
+ console.warn = originalWarn;
+ });
+
+ test("migrates legacy persisted keybindings before decoding them", () => {
+ const migrated = migratePersistedKeybindingsState({
+ state: {
+ keybindings: {
+ s: "split-selected",
+ "ctrl+v": "paste-selected",
+ },
+ isCustomized: true,
+ },
+ fromVersion: 2,
+ });
+
+ const decoded = decodePersistedKeybindingsState({ state: migrated });
+ expect(decoded).not.toBeNull();
+ if (!decoded) throw new Error("Expected migrated keybindings to decode");
+
+ expect(decoded.isCustomized).toBe(true);
+ expect(decoded.keybindings.get("s")).toBe("split");
+ expect(decoded.keybindings.get("ctrl+v")).toBe("paste-copied");
+ expect(decoded.keybindings.get("escape")).toBe("cancel-interaction");
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ test("filters invalid persisted entries at the boundary and warns", () => {
+ const decoded = decodePersistedKeybindingsState({
+ state: {
+ keybindings: {
+ space: "toggle-play",
+ "shift+bogus": "toggle-play",
+ "ctrl+v": "not-an-action",
+ },
+ isCustomized: false,
+ },
+ });
+
+ expect(decoded).not.toBeNull();
+ if (!decoded) throw new Error("Expected persisted keybindings to decode");
+
+ expect(Array.from(decoded.keybindings.entries())).toEqual([
+ ["space", "toggle-play"],
+ ]);
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test("returns null and warns when persisted shape is unrecognizable", () => {
+ const decoded = decodePersistedKeybindingsState({ state: "garbage" });
+ expect(decoded).toBeNull();
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test("round-trips actions that have no default shortcut", () => {
+ // `stop-playback` is a valid `TActionWithOptionalArgs` but is not in the
+ // defaults table; the validator must still accept it.
+ const serialized = serializeKeybindingsState({
+ keybindings: new Map([
+ ["space", "toggle-play"],
+ ["x", "stop-playback"],
+ ["b", "toggle-bookmark"],
+ ]),
+ isCustomized: true,
+ });
+
+ const decoded = decodePersistedKeybindingsState({ state: serialized });
+ expect(decoded).not.toBeNull();
+ if (!decoded) throw new Error("Expected round-tripped keybindings");
+
+ expect(decoded.keybindings.get("space")).toBe("toggle-play");
+ expect(decoded.keybindings.get("x")).toBe("stop-playback");
+ expect(decoded.keybindings.get("b")).toBe("toggle-bookmark");
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+});
+
+describe("parseImportedKeybindings", () => {
+ test("accepts a valid configuration", () => {
+ const result = parseImportedKeybindings({
+ config: {
+ space: "toggle-play",
+ x: "stop-playback",
+ },
+ });
+
+ expect(result.get("space")).toBe("toggle-play");
+ expect(result.get("x")).toBe("stop-playback");
+ });
+
+ test("throws on non-object input", () => {
+ expect(() => parseImportedKeybindings({ config: null })).toThrow(
+ /JSON object/,
+ );
+ expect(() => parseImportedKeybindings({ config: [] })).toThrow(
+ /JSON object/,
+ );
+ });
+
+ test("throws on non-string action values", () => {
+ expect(() =>
+ parseImportedKeybindings({ config: { space: 42 } }),
+ ).toThrow(/expected string/);
+ });
+
+ test("throws on invalid shortcut keys", () => {
+ expect(() =>
+ parseImportedKeybindings({
+ config: { "shift+bogus": "toggle-play" },
+ }),
+ ).toThrow(/shift\+bogus/);
+ });
+
+ test("throws on invalid actions", () => {
+ expect(() =>
+ parseImportedKeybindings({ config: { space: "not-an-action" } }),
+ ).toThrow(/not-an-action/);
+ });
+});
diff --git a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts
index 0171c5a5..81fd86a7 100644
--- a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts
+++ b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts
@@ -1,13 +1,8 @@
-import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
-import type { TActionWithOptionalArgs } from "@/actions";
-
-interface V2State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v2ToV3({ state }: { state: unknown }): unknown {
- const v2 = state as V2State;
+ const v2 = getPersistedKeybindingsState({ state });
+ if (!v2) return state;
const renames: Record<string, string> = {
"split-selected": "split",
@@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown {
const migrated = { ...v2.keybindings };
for (const [key, action] of Object.entries(migrated)) {
- if (action && renames[action]) {
- migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ const renamedAction = action ? renames[action] : undefined;
+ if (renamedAction) {
+ migrated[key] = renamedAction;
}
}
diff --git a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts
index 8541071b..3724f61c 100644
--- a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts
+++ b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts
@@ -1,14 +1,8 @@
-import type { TActionWithOptionalArgs } from "@/actions";
-import type { ShortcutKey } from "@/actions/keybinding";
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V3State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v3ToV4({ state }: { state: unknown }): unknown {
- const v3 = state as V3State;
+ const v3 = getPersistedKeybindingsState({ state });
+ if (!v3) return state;
const renames: Record<string, string> = {
"paste-selected": "paste-copied",
@@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown {
const migrated = { ...v3.keybindings };
for (const [key, action] of Object.entries(migrated)) {
- if (action && renames[action]) {
- migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ const renamedAction = action ? renames[action] : undefined;
+ if (renamedAction) {
+ migrated[key] = renamedAction;
}
}
diff --git a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts
index f6937c39..bb60b044 100644
--- a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts
+++ b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts
@@ -1,12 +1,8 @@
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V4State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v4ToV5({ state }: { state: unknown }): unknown {
- const v4 = state as V4State;
+ const v4 = getPersistedKeybindingsState({ state });
+ if (!v4) return state;
const keybindings = { ...v4.keybindings };
if (!keybindings.escape) {
diff --git a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts
index 09b44307..cb572aac 100644
--- a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts
+++ b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts
@@ -1,12 +1,8 @@
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V5State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v5ToV6({ state }: { state: unknown }): unknown {
- const v5 = state as V5State;
+ const v5 = getPersistedKeybindingsState({ state });
+ if (!v5) return state;
const keybindings = { ...v5.keybindings };
if (keybindings.escape === "deselect-all") {
diff --git a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts
index 46a99ac1..9c05db23 100644
--- a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts
+++ b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts
@@ -1,16 +1,12 @@
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V6State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v6ToV7({ state }: { state: unknown }): unknown {
- const v6 = state as V6State;
+ const v6 = getPersistedKeybindingsState({ state });
+ if (!v6) return state;
const keybindings = { ...v6.keybindings };
- for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) {
- if (keybindings[key] === ("split-element" as never)) {
+ for (const [key, action] of Object.entries(keybindings)) {
+ if (action === "split-element") {
keybindings[key] = "split";
}
}
diff --git a/apps/web/src/actions/keybindings/persisted-state.ts b/apps/web/src/actions/keybindings/persisted-state.ts
new file mode 100644
index 00000000..57d83f6d
--- /dev/null
+++ b/apps/web/src/actions/keybindings/persisted-state.ts
@@ -0,0 +1,37 @@
+export type PersistedKeybindingConfig = Record<string, string | undefined>;
+
+export interface PersistedKeybindingsState {
+ keybindings: PersistedKeybindingConfig;
+ isCustomized: boolean;
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export function getPersistedKeybindingsState({
+ state,
+}: {
+ state: unknown;
+}): PersistedKeybindingsState | null {
+ if (!isRecord(state)) return null;
+
+ const { keybindings, isCustomized } = state;
+ if (!isRecord(keybindings) || typeof isCustomized !== "boolean") {
+ return null;
+ }
+
+ const normalizedKeybindings: PersistedKeybindingConfig = {};
+ for (const [key, action] of Object.entries(keybindings)) {
+ if (action !== undefined && typeof action !== "string") {
+ return null;
+ }
+
+ normalizedKeybindings[key] = action;
+ }
+
+ return {
+ keybindings: normalizedKeybindings,
+ isCustomized,
+ };
+}
diff --git a/apps/web/src/actions/keybindings/persistence.ts b/apps/web/src/actions/keybindings/persistence.ts
new file mode 100644
index 00000000..ced44bd0
--- /dev/null
+++ b/apps/web/src/actions/keybindings/persistence.ts
@@ -0,0 +1,119 @@
+import type { ShortcutKey } from "@/actions/keybinding";
+import { isShortcutKey } from "@/actions/keybinding";
+import type { TActionWithOptionalArgs } from "@/actions";
+import { isActionWithOptionalArgs } from "@/actions";
+import { runMigrations } from "./migrations";
+import {
+ getPersistedKeybindingsState,
+ type PersistedKeybindingsState,
+} from "./persisted-state";
+
+export interface DecodedKeybindingsState {
+ keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
+ isCustomized: boolean;
+}
+
+export function serializeKeybindingsState({
+ keybindings,
+ isCustomized,
+}: DecodedKeybindingsState): PersistedKeybindingsState {
+ return {
+ keybindings: Object.fromEntries(keybindings),
+ isCustomized,
+ };
+}
+
+export function migratePersistedKeybindingsState({
+ state,
+ fromVersion,
+}: {
+ state: unknown;
+ fromVersion: number;
+}): unknown {
+ return runMigrations({ state, fromVersion });
+}
+
+/**
+ * Decode a persisted/migrated keybindings blob into the in-memory shape.
+ *
+ * Lossy by design: invalid entries are dropped and a warning is emitted, so the
+ * user falls back to (mostly) sensible defaults instead of a broken store.
+ * Returns `null` if the top-level shape is unrecognizable, in which case the
+ * caller should keep its current state.
+ */
+export function decodePersistedKeybindingsState({
+ state,
+}: {
+ state: unknown;
+}): DecodedKeybindingsState | null {
+ const persisted = getPersistedKeybindingsState({ state });
+ if (!persisted) {
+ console.warn(
+ "[keybindings] Persisted state has unexpected shape; keeping current keybindings.",
+ state,
+ );
+ return null;
+ }
+
+ const keybindings = new Map<ShortcutKey, TActionWithOptionalArgs>();
+ const dropped: Array<{ key: string; action: string | undefined }> = [];
+ for (const [key, action] of Object.entries(persisted.keybindings)) {
+ if (action === undefined) continue;
+ if (!isShortcutKey(key) || !isActionWithOptionalArgs(action)) {
+ dropped.push({ key, action });
+ continue;
+ }
+
+ keybindings.set(key, action);
+ }
+
+ if (dropped.length > 0) {
+ console.warn(
+ "[keybindings] Dropped invalid persisted entries:",
+ dropped,
+ );
+ }
+
+ return {
+ keybindings,
+ isCustomized: persisted.isCustomized,
+ };
+}
+
+/**
+ * Parse a user-supplied keybindings configuration (typically the output of
+ * `JSON.parse` on an imported file).
+ *
+ * Strict by design: throws on the first invalid entry so the caller can surface
+ * the failure to the user instead of silently producing a half-applied import.
+ * Accepts `unknown` because the input has already crossed a trust boundary.
+ */
+export function parseImportedKeybindings({
+ config,
+}: {
+ config: unknown;
+}): Map<ShortcutKey, TActionWithOptionalArgs> {
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
+ throw new Error("Imported keybindings must be a JSON object");
+ }
+
+ const result = new Map<ShortcutKey, TActionWithOptionalArgs>();
+ for (const [key, action] of Object.entries(config)) {
+ if (action === undefined) continue;
+ if (typeof action !== "string") {
+ throw new Error(
+ `Invalid action for "${key}": expected string, got ${typeof action}`,
+ );
+ }
+ if (!isShortcutKey(key)) {
+ throw new Error(`Invalid shortcut key: ${JSON.stringify(key)}`);
+ }
+ if (!isActionWithOptionalArgs(action)) {
+ throw new Error(
+ `Invalid action for "${key}": ${JSON.stringify(action)}`,
+ );
+ }
+ result.set(key, action);
+ }
+ return result;
+}
diff --git a/apps/web/src/actions/registry.ts b/apps/web/src/actions/registry.ts
index e69b3b9f..ece14eaf 100644
--- a/apps/web/src/actions/registry.ts
+++ b/apps/web/src/actions/registry.ts
@@ -11,6 +11,7 @@ import type {
type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
+// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
export function bindAction<A extends TAction>(
action: A,
handler: TActionFunc<A>,
@@ -24,6 +25,7 @@ export function bindAction<A extends TAction>(
}
}
+// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
export function unbindAction<A extends TAction>(
action: A,
handler: TActionFunc<A>,
@@ -52,6 +54,7 @@ type InvokeActionFunc = {
): void;
};
+// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger.
export const invokeAction: InvokeActionFunc = <A extends TAction>(
action: A,
args?: TArgOfAction<A>,
diff --git a/apps/web/src/actions/types.ts b/apps/web/src/actions/types.ts
index 41258c7b..38627b95 100644
--- a/apps/web/src/actions/types.ts
+++ b/apps/web/src/actions/types.ts
@@ -1,44 +1,44 @@
-import type { MutableRefObject } from "react";
-import type { TAction } from "./definitions";
-
-export type { TAction };
-
-export type TActionArgsMap = {
- "seek-forward": { seconds: number } | undefined;
- "seek-backward": { seconds: number } | undefined;
- "jump-forward": { seconds: number } | undefined;
- "jump-backward": { seconds: number } | undefined;
- "remove-media-asset": { projectId: string; assetId: string };
- "remove-media-assets": { projectId: string; assetIds: string[] };
-};
-
-type TKeysWithValueUndefined<T> = {
- [K in keyof T]: undefined extends T[K] ? K : never;
-}[keyof T];
-
-export type TActionWithArgs = keyof TActionArgsMap;
-
-export type TActionWithOptionalArgs =
- | TActionWithNoArgs
- | TKeysWithValueUndefined<TActionArgsMap>;
-
-export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
-
-export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
- ? TActionArgsMap[A]
- : undefined;
-
-export type TActionFunc<A extends TAction> = A extends TActionWithArgs
- ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
- : (_?: undefined, trigger?: TInvocationTrigger) => void;
-
-export type TInvocationTrigger = "keypress" | "mouseclick";
-
-export type TBoundActionList = {
- [A in TAction]?: Array<TActionFunc<A>>;
-};
-
-export type TActionHandlerOptions =
- | MutableRefObject<boolean>
- | boolean
- | undefined;
+import type { MutableRefObject } from "react";
+import type { TAction } from "./definitions";
+
+export type { TAction };
+
+export type TActionArgsMap = {
+ "seek-forward": { seconds: number } | undefined;
+ "seek-backward": { seconds: number } | undefined;
+ "jump-forward": { seconds: number } | undefined;
+ "jump-backward": { seconds: number } | undefined;
+ "remove-media-asset": { projectId: string; assetId: string };
+ "remove-media-assets": { projectId: string; assetIds: string[] };
+};
+
+type TKeysWithValueUndefined<T> = {
+ [K in keyof T]: undefined extends T[K] ? K : never;
+}[keyof T];
+
+export type TActionWithArgs = keyof TActionArgsMap;
+
+export type TActionWithOptionalArgs =
+ | TActionWithNoArgs
+ | TKeysWithValueUndefined<TActionArgsMap>;
+
+export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
+
+export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
+ ? TActionArgsMap[A]
+ : undefined;
+
+export type TActionFunc<A extends TAction> = A extends TActionWithArgs
+ ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
+ : (_?: undefined, trigger?: TInvocationTrigger) => void;
+
+export type TInvocationTrigger = "keypress" | "mouseclick";
+
+export type TBoundActionList = {
+ [A in TAction]?: Array<TActionFunc<A>>;
+};
+
+export type TActionHandlerOptions =
+ | MutableRefObject<boolean>
+ | boolean
+ | undefined;
diff --git a/apps/web/src/actions/use-action-handler.ts b/apps/web/src/actions/use-action-handler.ts
index a8bb0054..d4a37b50 100644
--- a/apps/web/src/actions/use-action-handler.ts
+++ b/apps/web/src/actions/use-action-handler.ts
@@ -8,6 +8,7 @@ import type {
} from "@/actions";
import { bindAction, unbindAction } from "@/actions";
+// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive).
export function useActionHandler<A extends TAction>(
action: A,
handler: TActionFunc<A>,
diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts
index e62a8ce0..e5d3a23b 100644
--- a/apps/web/src/actions/use-editor-actions.ts
+++ b/apps/web/src/actions/use-editor-actions.ts
@@ -1,11 +1,20 @@
"use client";
-import { useEffect, useRef } from "react";
+import { useEffect, useState } from "react";
import { useTimelineStore } from "@/timeline/timeline-store";
import { useActionHandler } from "@/actions/use-action-handler";
import { useEditor } from "@/editor/use-editor";
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ addMediaTime,
+ maxMediaTime,
+ mediaTime,
+ mediaTimeFromSeconds,
+ minMediaTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
import { getElementsAtTime, hasMediaId } from "@/timeline";
import { cancelInteraction } from "@/editor/cancel-interaction";
@@ -16,6 +25,7 @@ import {
clearActiveScope,
type ScopeEntry,
} from "@/selection/scope";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
export function useEditorActions() {
const editor = useEditor();
@@ -27,47 +37,34 @@ export function useEditorActions() {
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
- const hasTimelineSelectionRef = useRef(false);
- const clearTimelineSelectionRef = useRef(() => {});
- const clearTimelineActiveSelectionRef = useRef(() => {});
- const timelineScopeRef = useRef<ScopeEntry | null>(null);
const hasTimelineSelection =
selectedElements.length > 0 ||
selectedKeyframes.length > 0 ||
selectedMaskPointSelection !== null;
-
- hasTimelineSelectionRef.current = hasTimelineSelection;
- clearTimelineSelectionRef.current = () => {
+ const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection);
+ const clearTimelineSelectionRef = useCommittedRef(() => {
editor.selection.clearSelection();
- };
- clearTimelineActiveSelectionRef.current = () => {
+ });
+ const clearTimelineActiveSelectionRef = useCommittedRef(() => {
editor.selection.clearMostSpecificSelection();
- };
-
- if (!timelineScopeRef.current) {
- timelineScopeRef.current = {
- hasSelection: () => hasTimelineSelectionRef.current,
- clear: () => {
- clearTimelineSelectionRef.current();
- },
- clearActive: () => {
- clearTimelineActiveSelectionRef.current();
- },
- };
- }
+ });
+ const [timelineScope] = useState<ScopeEntry>(() => ({
+ hasSelection: () => hasTimelineSelectionRef.current,
+ clear: () => {
+ clearTimelineSelectionRef.current();
+ },
+ clearActive: () => {
+ clearTimelineActiveSelectionRef.current();
+ },
+ }));
useEffect(() => {
if (!hasTimelineSelection) {
return;
}
- const timelineScope = timelineScopeRef.current;
- if (!timelineScope) {
- return;
- }
-
return activateScope({ entry: timelineScope });
- }, [hasTimelineSelection]);
+ }, [hasTimelineSelection, timelineScope]);
useActionHandler(
"toggle-play",
@@ -83,7 +80,7 @@ export function useEditorActions() {
if (editor.playback.getIsPlaying()) {
editor.playback.toggle();
}
- editor.playback.seek({ time: 0 });
+ editor.playback.seek({ time: ZERO_MEDIA_TIME });
},
undefined,
);
@@ -92,11 +89,15 @@ export function useEditorActions() {
"seek-forward",
(args) => {
const seconds = args?.seconds ?? 1;
+ const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
- time: Math.min(
- editor.timeline.getTotalDuration(),
- editor.playback.getCurrentTime() + seconds,
- ),
+ time: minMediaTime({
+ a: editor.timeline.getTotalDuration(),
+ b: addMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: delta,
+ }),
+ }),
});
},
undefined,
@@ -106,8 +107,15 @@ export function useEditorActions() {
"seek-backward",
(args) => {
const seconds = args?.seconds ?? 1;
+ const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
- time: Math.max(0, editor.playback.getCurrentTime() - seconds),
+ time: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: delta,
+ }),
+ }),
});
},
undefined,
@@ -117,15 +125,20 @@ export function useEditorActions() {
"frame-step-forward",
() => {
const fps = editor.project.getActive().settings.fps;
- const ticksPerFrame = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
- editor.playback.seek({
- time: Math.min(
- editor.timeline.getTotalDuration(),
- editor.playback.getCurrentTime() + ticksPerFrame,
+ const ticksPerFrame = mediaTime({
+ ticks: Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
),
});
+ editor.playback.seek({
+ time: minMediaTime({
+ a: editor.timeline.getTotalDuration(),
+ b: addMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: ticksPerFrame,
+ }),
+ }),
+ });
},
undefined,
);
@@ -134,11 +147,19 @@ export function useEditorActions() {
"frame-step-backward",
() => {
const fps = editor.project.getActive().settings.fps;
- const ticksPerFrame = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
+ const ticksPerFrame = mediaTime({
+ ticks: Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ ),
+ });
editor.playback.seek({
- time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
+ time: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: ticksPerFrame,
+ }),
+ }),
});
},
undefined,
@@ -148,11 +169,15 @@ export function useEditorActions() {
"jump-forward",
(args) => {
const seconds = args?.seconds ?? 5;
+ const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
- time: Math.min(
- editor.timeline.getTotalDuration(),
- editor.playback.getCurrentTime() + seconds,
- ),
+ time: minMediaTime({
+ a: editor.timeline.getTotalDuration(),
+ b: addMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: delta,
+ }),
+ }),
});
},
undefined,
@@ -162,8 +187,15 @@ export function useEditorActions() {
"jump-backward",
(args) => {
const seconds = args?.seconds ?? 5;
+ const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({
- time: Math.max(0, editor.playback.getCurrentTime() - seconds),
+ time: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({
+ a: editor.playback.getCurrentTime(),
+ b: delta,
+ }),
+ }),
});
},
undefined,
@@ -172,7 +204,7 @@ export function useEditorActions() {
useActionHandler(
"goto-start",
() => {
- editor.playback.seek({ time: 0 });
+ editor.playback.seek({ time: ZERO_MEDIA_TIME });
},
undefined,
);
@@ -273,7 +305,7 @@ export function useEditorActions() {
if (!selectedMaskPointSelection) {
return;
}
- editor.timeline.deleteCustomMaskPoints({
+ editor.timeline.deleteFreeformPathMaskPoints({
trackId: selectedMaskPointSelection.trackId,
elementId: selectedMaskPointSelection.elementId,
maskId: selectedMaskPointSelection.maskId,
diff --git a/apps/web/src/actions/use-keybindings.ts b/apps/web/src/actions/use-keybindings.ts
index c129795c..e9d78982 100644
--- a/apps/web/src/actions/use-keybindings.ts
+++ b/apps/web/src/actions/use-keybindings.ts
@@ -33,7 +33,7 @@ export function useKeybindingsListener() {
const isTextInput =
activeElement instanceof HTMLElement &&
isTypableDOMElement({ element: activeElement });
- const boundAction = binding ? keybindings[binding] : undefined;
+ const boundAction = binding ? keybindings.get(binding) : undefined;
if (normalizedKey === "escape" && isTextInput) {
activeElement.blur();
diff --git a/apps/web/src/actions/use-keyboard-shortcuts-help.ts b/apps/web/src/actions/use-keyboard-shortcuts-help.ts
index 7681e664..89a17887 100644
--- a/apps/web/src/actions/use-keyboard-shortcuts-help.ts
+++ b/apps/web/src/actions/use-keyboard-shortcuts-help.ts
@@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() {
const { keybindings } = useKeybindingsStore();
const shortcuts = useMemo(() => {
- const result: KeyboardShortcut[] = [];
- const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {};
+ const actionToKeys = new Map<TActionWithOptionalArgs, string[]>();
- for (const [key, action] of Object.entries(keybindings) as Array<
- [string, TActionWithOptionalArgs | undefined]
- >) {
- if (action) {
- if (!actionToKeys[action]) {
- actionToKeys[action] = [];
- }
- actionToKeys[action].push(formatKey({ key }));
+ for (const [key, action] of keybindings) {
+ const existing = actionToKeys.get(action);
+ if (existing) {
+ existing.push(formatKey({ key }));
+ } else {
+ actionToKeys.set(action, [formatKey({ key })]);
}
}
- for (const [action, keys] of Object.entries(actionToKeys) as Array<
- [TActionWithOptionalArgs, string[]]
- >) {
+ const result: KeyboardShortcut[] = [];
+ for (const [action, keys] of actionToKeys) {
const actionDef = ACTIONS[action];
- if (!actionDef) {
- continue;
- }
+ if (!actionDef) continue;
result.push({
id: action,
keys,
diff --git a/apps/web/src/animation/__tests__/animated-params.test.ts b/apps/web/src/animation/__tests__/animated-params.test.ts
index 7b9d312d..a47b0382 100644
--- a/apps/web/src/animation/__tests__/animated-params.test.ts
+++ b/apps/web/src/animation/__tests__/animated-params.test.ts
@@ -1,15 +1,15 @@
import { describe, expect, test } from "bun:test";
import {
- coerceAnimationParamValue,
- getAnimationParamDefaultInterpolation,
- getAnimationParamNumericRange,
- getAnimationParamValueKind,
-} from "@/animation/animated-params";
+ coerceParamValue,
+ getParamDefaultInterpolation,
+ getParamNumericRange,
+ getParamValueKind,
+} from "@/params";
describe("animated params", () => {
test("snaps and clamps number params", () => {
expect(
- coerceAnimationParamValue({
+ coerceParamValue({
param: {
key: "intensity",
label: "Intensity",
@@ -24,7 +24,7 @@ describe("animated params", () => {
).toBe(0.5);
expect(
- coerceAnimationParamValue({
+ coerceParamValue({
param: {
key: "intensity",
label: "Intensity",
@@ -49,14 +49,14 @@ describe("animated params", () => {
max: 1,
step: 0.25,
};
- expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull();
- expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull();
- expect(coerceAnimationParamValue({ param, value: true })).toBeNull();
+ expect(coerceParamValue({ param, value: Number.NaN })).toBeNull();
+ expect(coerceParamValue({ param, value: "0.5" })).toBeNull();
+ expect(coerceParamValue({ param, value: true })).toBeNull();
});
test("passthrough with step <= 0 guard", () => {
expect(
- coerceAnimationParamValue({
+ coerceParamValue({
param: {
key: "x",
label: "X",
@@ -81,13 +81,13 @@ describe("animated params", () => {
{ value: "multiply", label: "Multiply" },
],
};
- expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal");
- expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply");
+ expect(coerceParamValue({ param, value: "normal" })).toBe("normal");
+ expect(coerceParamValue({ param, value: "multiply" })).toBe("multiply");
});
test("rejects select values outside the allowed options", () => {
expect(
- coerceAnimationParamValue({
+ coerceParamValue({
param: {
key: "blend",
label: "Blend",
@@ -111,9 +111,9 @@ describe("animated params", () => {
default: "normal",
options: [{ value: "normal", label: "Normal" }],
};
- expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull();
- expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
- expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull();
+ expect(coerceParamValue({ param, value: 42 })).toBeNull();
+ expect(coerceParamValue({ param, value: null })).toBeNull();
+ expect(coerceParamValue({ param, value: undefined })).toBeNull();
});
test("boolean params accept booleans and reject other types", () => {
@@ -123,10 +123,10 @@ describe("animated params", () => {
type: "boolean" as const,
default: true,
};
- expect(coerceAnimationParamValue({ param, value: true })).toBe(true);
- expect(coerceAnimationParamValue({ param, value: false })).toBe(false);
- expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull();
- expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull();
+ expect(coerceParamValue({ param, value: true })).toBe(true);
+ expect(coerceParamValue({ param, value: false })).toBe(false);
+ expect(coerceParamValue({ param, value: 1 })).toBeNull();
+ expect(coerceParamValue({ param, value: "true" })).toBeNull();
});
test("color params accept strings and reject other types", () => {
@@ -136,14 +136,14 @@ describe("animated params", () => {
type: "color" as const,
default: "#ffffff",
};
- expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
- expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull();
- expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
+ expect(coerceParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
+ expect(coerceParamValue({ param, value: 0xff0000 })).toBeNull();
+ expect(coerceParamValue({ param, value: null })).toBeNull();
});
test("getAnimationParamValueKind maps param type to binding kind", () => {
expect(
- getAnimationParamValueKind({
+ getParamValueKind({
param: {
key: "n",
label: "N",
@@ -155,17 +155,17 @@ describe("animated params", () => {
}),
).toBe("number");
expect(
- getAnimationParamValueKind({
+ getParamValueKind({
param: { key: "c", label: "C", type: "color", default: "#fff" },
}),
).toBe("color");
expect(
- getAnimationParamValueKind({
+ getParamValueKind({
param: { key: "b", label: "B", type: "boolean", default: false },
}),
).toBe("discrete");
expect(
- getAnimationParamValueKind({
+ getParamValueKind({
param: {
key: "s",
label: "S",
@@ -179,7 +179,7 @@ describe("animated params", () => {
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
expect(
- getAnimationParamDefaultInterpolation({
+ getParamDefaultInterpolation({
param: {
key: "n",
label: "N",
@@ -191,17 +191,17 @@ describe("animated params", () => {
}),
).toBe("linear");
expect(
- getAnimationParamDefaultInterpolation({
+ getParamDefaultInterpolation({
param: { key: "c", label: "C", type: "color", default: "#fff" },
}),
).toBe("linear");
expect(
- getAnimationParamDefaultInterpolation({
+ getParamDefaultInterpolation({
param: { key: "b", label: "B", type: "boolean", default: false },
}),
).toBe("hold");
expect(
- getAnimationParamDefaultInterpolation({
+ getParamDefaultInterpolation({
param: {
key: "s",
label: "S",
@@ -215,7 +215,7 @@ describe("animated params", () => {
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
expect(
- getAnimationParamNumericRange({
+ getParamNumericRange({
param: {
key: "intensity",
label: "Intensity",
@@ -228,12 +228,12 @@ describe("animated params", () => {
}),
).toEqual({ min: 0, max: 1, step: 0.1 });
expect(
- getAnimationParamNumericRange({
+ getParamNumericRange({
param: { key: "c", label: "C", type: "color", default: "#fff" },
}),
).toBeUndefined();
expect(
- getAnimationParamNumericRange({
+ getParamNumericRange({
param: { key: "b", label: "B", type: "boolean", default: false },
}),
).toBeUndefined();
diff --git a/apps/web/src/animation/__tests__/binding-values.test.ts b/apps/web/src/animation/__tests__/binding-values.test.ts
deleted file mode 100644
index a1f2010c..00000000
--- a/apps/web/src/animation/__tests__/binding-values.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- composeAnimationValue,
- createAnimationBinding,
-} from "@/animation/binding-values";
-
-describe("binding values", () => {
- test("formats composed animated colors as hex", () => {
- const binding = createAnimationBinding({
- path: "color",
- kind: "color",
- });
-
- expect(
- composeAnimationValue({
- binding,
- componentValues: {
- r: 1,
- g: 0,
- b: 0,
- a: 1,
- },
- }),
- ).toBe("#ff0000");
- });
-});
diff --git a/apps/web/src/animation/animated-params.ts b/apps/web/src/animation/animated-params.ts
deleted file mode 100644
index bc27b15d..00000000
--- a/apps/web/src/animation/animated-params.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { snapToStep } from "@/utils/math";
-import type { ParamDefinition } from "@/params";
-import type { DynamicAnimationPathValue, NumericSpec } from "./types";
-
-export function getAnimationParamValueKind({
- param,
-}: {
- param: ParamDefinition;
-}): "number" | "color" | "discrete" {
- if (param.type === "number") {
- return "number";
- }
-
- if (param.type === "color") {
- return "color";
- }
-
- return "discrete";
-}
-
-export function getAnimationParamDefaultInterpolation({
- param,
-}: {
- param: ParamDefinition;
-}): "linear" | "hold" {
- return param.type === "number" || param.type === "color" ? "linear" : "hold";
-}
-
-export function getAnimationParamNumericRange({
- param,
-}: {
- param: ParamDefinition;
-}): NumericSpec | undefined {
- if (param.type !== "number") {
- return undefined;
- }
-
- return {
- min: param.min,
- max: param.max,
- step: param.step,
- };
-}
-
-/**
- * `coerceAnimationParamValue` accepts `unknown` rather than a narrow
- * `DynamicAnimationPathValue` because it doubles as a runtime gate for
- * untrusted inputs (persisted state, paste payloads, programmatic updates).
- * The caller does not need to pre-validate; null means "this value cannot live
- * on this param".
- */
-export function coerceAnimationParamValue({
- param,
- value,
-}: {
- param: ParamDefinition;
- value: unknown;
-}): DynamicAnimationPathValue | 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;
-}
diff --git a/apps/web/src/animation/binding-values.ts b/apps/web/src/animation/binding-values.ts
deleted file mode 100644
index a46aa9a6..00000000
--- a/apps/web/src/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 "@/animation/types";
-import { clamp } from "@/utils/math";
-
-interface LinearRgba {
- r: number;
- g: number;
- b: number;
- a: number;
-}
-
-export type AnimationComponentValue = number | DiscreteValue;
-
-const toRgb = converter("rgb");
-
-function srgbToLinear({ value }: { value: number }): number {
- return value <= 0.04045
- ? value / 12.92
- : Math.pow((value + 0.055) / 1.055, 2.4);
-}
-
-function linearToSrgb({ value }: { value: number }): number {
- const clamped = clamp({ value, min: 0, max: 1 });
- return clamped <= 0.0031308
- ? clamped * 12.92
- : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
-}
-
-function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null;
-}
-
-export function isVectorValue(value: unknown): value is VectorValue {
- return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
-}
-
-export type EasingMode = "independent" | "shared";
-
-/**
- * Declares how easing curves apply to a binding's components.
- * "shared" means all components always use the same curve (e.g. color — you never
- * want to ease R independently from G/B/A). "independent" means each component
- * can have its own curve.
- */
-export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
- return kind === "color" ? "shared" : "independent";
-}
-
-export function getBindingComponentKeys({
- kind,
-}: {
- kind: AnimationBindingKind;
-}): string[] {
- if (kind === "vector2") {
- return ["x", "y"];
- }
-
- if (kind === "color") {
- return ["r", "g", "b", "a"];
- }
-
- return ["value"];
-}
-
-export function buildBindingChannelId({
- path,
- componentKey,
-}: {
- path: AnimationPath;
- componentKey: string;
-}): string {
- return `${path}:${componentKey}`;
-}
-
-function createBindingComponent<TKey extends string>({
- path,
- key,
-}: {
- path: AnimationPath;
- key: TKey;
-}): AnimationBindingComponent<TKey> {
- return {
- key,
- channelId: buildBindingChannelId({ path, componentKey: key }),
- };
-}
-
-function cloneBindingComponents<TKey extends string>({
- components,
-}: {
- components: AnimationBindingComponent<TKey>[];
-}): AnimationBindingComponent<TKey>[] {
- return components.map((component) => ({ ...component }));
-}
-
-const animationBindingFactories = {
- color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
- path,
- kind: "color",
- colorSpace: "srgb-linear",
- components: [
- createBindingComponent({ path, key: "r" }),
- createBindingComponent({ path, key: "g" }),
- createBindingComponent({ path, key: "b" }),
- createBindingComponent({ path, key: "a" }),
- ],
- }),
- vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
- path,
- kind: "vector2",
- components: [
- createBindingComponent({ path, key: "x" }),
- createBindingComponent({ path, key: "y" }),
- ],
- }),
- number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
- path,
- kind: "number",
- components: [createBindingComponent({ path, key: "value" })],
- }),
- discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
- path,
- kind: "discrete",
- components: [createBindingComponent({ path, key: "value" })],
- }),
-} satisfies {
- [K in AnimationBindingKind]: ({
- path,
- }: {
- path: AnimationPath;
- }) => AnimationBindingOfKind<K>;
-};
-
-export function createAnimationBinding<TKind extends AnimationBindingKind>({
- path,
- kind,
-}: {
- path: AnimationPath;
- kind: TKind;
-}): AnimationBindingOfKind<TKind>;
-export function createAnimationBinding({
- path,
- kind,
-}: {
- path: AnimationPath;
- kind: AnimationBindingKind;
-}): AnimationBindingInstance {
- return animationBindingFactories[kind]({ path });
-}
-
-const animationBindingCloners = {
- color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
- vector2: ({
- binding,
- }: {
- binding: Vector2AnimationBinding;
- }): Vector2AnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
- number: ({
- binding,
- }: {
- binding: NumberAnimationBinding;
- }): NumberAnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
- discrete: ({
- binding,
- }: {
- binding: DiscreteAnimationBinding;
- }): DiscreteAnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
-} satisfies {
- [K in AnimationBindingKind]: ({
- binding,
- }: {
- binding: AnimationBindingOfKind<K>;
- }) => AnimationBindingOfKind<K>;
-};
-
-export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
- binding,
-}: {
- binding: AnimationBindingOfKind<TKind>;
-}): AnimationBindingOfKind<TKind>;
-export function cloneAnimationBinding({
- binding,
-}: {
- binding: AnimationBindingInstance;
-}): AnimationBindingInstance {
- switch (binding.kind) {
- case "color":
- return animationBindingCloners.color({ binding });
- case "vector2":
- return animationBindingCloners.vector2({ binding });
- case "number":
- return animationBindingCloners.number({ binding });
- case "discrete":
- return animationBindingCloners.discrete({ binding });
- }
-}
-
-export function parseColorToLinearRgba({
- color,
-}: {
- color: string;
-}): LinearRgba | null {
- const parsed = parse(color);
- const rgb = parsed ? toRgb(parsed) : null;
- if (!rgb) {
- return null;
- }
-
- return {
- r: srgbToLinear({ value: rgb.r ?? 0 }),
- g: srgbToLinear({ value: rgb.g ?? 0 }),
- b: srgbToLinear({ value: rgb.b ?? 0 }),
- a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }),
- };
-}
-
-export function formatLinearRgba({
- color,
-}: {
- color: LinearRgba;
-}): string {
- const rgb = {
- mode: "rgb",
- r: linearToSrgb({ value: color.r }),
- g: linearToSrgb({ value: color.g }),
- b: linearToSrgb({ value: color.b }),
- alpha: clamp({ value: color.a, min: 0, max: 1 }),
- } as const;
- return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
-}
-
-export function decomposeAnimationValue({
- kind,
- value,
-}: {
- kind: AnimationBindingKind;
- value: AnimationValue;
-}): Record<string, AnimationComponentValue> | null {
- if (kind === "number") {
- return typeof value === "number" ? { value } : null;
- }
-
- if (kind === "vector2") {
- return isVectorValue(value) ? { x: value.x, y: value.y } : null;
- }
-
- if (kind === "color") {
- if (typeof value !== "string") {
- return null;
- }
- const parsed = parseColorToLinearRgba({ color: value });
- if (!parsed) {
- return null;
- }
- return {
- r: parsed.r,
- g: parsed.g,
- b: parsed.b,
- a: parsed.a,
- };
- }
-
- return typeof value === "string" || typeof value === "boolean"
- ? { value }
- : null;
-}
-
-export function composeAnimationValue({
- binding,
- componentValues,
-}: {
- binding: AnimationBindingInstance;
- componentValues: Record<string, AnimationComponentValue | undefined>;
-}): AnimationValue | null {
- if (binding.kind === "number") {
- const value = componentValues.value;
- return typeof value === "number" ? value : null;
- }
-
- if (binding.kind === "vector2") {
- const x = componentValues.x;
- const y = componentValues.y;
- return typeof x === "number" && typeof y === "number" ? { x, y } : null;
- }
-
- if (binding.kind === "color") {
- const r = componentValues.r;
- const g = componentValues.g;
- const b = componentValues.b;
- const a = componentValues.a;
- if (
- typeof r !== "number" ||
- typeof g !== "number" ||
- typeof b !== "number" ||
- typeof a !== "number"
- ) {
- return null;
- }
- return formatLinearRgba({
- color: {
- r,
- g,
- b,
- a,
- },
- });
- }
-
- const value = componentValues.value;
- return typeof value === "string" || typeof value === "boolean" ? value : null;
-}
diff --git a/apps/web/src/animation/channel-data.ts b/apps/web/src/animation/channel-data.ts
new file mode 100644
index 00000000..2fc08b5c
--- /dev/null
+++ b/apps/web/src/animation/channel-data.ts
@@ -0,0 +1,57 @@
+import type {
+ AnimationChannel,
+ ChannelData,
+ CompositeChannelData,
+} from "@/animation/types";
+
+const LEGACY_ANIMATION_STORAGE_KEYS = new Set(["bindings", "channels"]);
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null;
+}
+
+export function isLeafChannelData(
+ data: ChannelData | undefined,
+): data is AnimationChannel {
+ return isRecord(data) && Array.isArray(data.keys);
+}
+
+export function isCompositeChannelData(
+ data: ChannelData | undefined,
+): data is CompositeChannelData {
+ return isRecord(data) && !Array.isArray(data.keys);
+}
+
+export function getChannelsFromData({
+ data,
+}: {
+ data: ChannelData | undefined;
+}): AnimationChannel[] {
+ if (isLeafChannelData(data)) {
+ return [data];
+ }
+ if (!isCompositeChannelData(data)) {
+ return [];
+ }
+ return Object.values(data).filter(isLeafChannelData);
+}
+
+export function getChannelEntriesFromData({
+ data,
+}: {
+ data: ChannelData | undefined;
+}): Array<[string, AnimationChannel]> {
+ if (isLeafChannelData(data)) {
+ return [["value", data]];
+ }
+ if (!isCompositeChannelData(data)) {
+ return [];
+ }
+ return Object.entries(data).flatMap(([componentKey, channel]) =>
+ isLeafChannelData(channel) ? [[componentKey, channel]] : [],
+ );
+}
+
+export function isAnimationStorageKey({ key }: { key: string }): boolean {
+ return !LEGACY_ANIMATION_STORAGE_KEYS.has(key);
+}
diff --git a/apps/web/src/animation/curve-bridge.ts b/apps/web/src/animation/curve-bridge.ts
index af411dd1..c947e7dc 100644
--- a/apps/web/src/animation/curve-bridge.ts
+++ b/apps/web/src/animation/curve-bridge.ts
@@ -7,6 +7,7 @@ import type {
NormalizedCubicBezier,
ScalarAnimationKey,
} from "@/animation/types";
+import { roundMediaTime } from "@/wasm";
const VALUE_EPSILON = 1e-6;
@@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
return {
rightHandle: {
- dt: spanTime * x1,
+ dt: roundMediaTime({ time: spanTime * x1 }),
dv: effectiveSpanValue * y1,
},
leftHandle: {
- dt: spanTime * (x2 - 1),
+ dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
dv: effectiveSpanValue * (y2 - 1),
},
};
diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts
index e1c442f7..3def2109 100644
--- a/apps/web/src/animation/effect-param-channel.ts
+++ b/apps/web/src/animation/effect-param-channel.ts
@@ -70,7 +70,7 @@ export function resolveEffectParamsAtTime({
for (const [paramKey, staticValue] of Object.entries(params)) {
const path = buildEffectParamPath({ effectId, paramKey });
- resolved[paramKey] = animations?.bindings[path]
+ resolved[paramKey] = animations?.[path]
? resolveAnimationPathValueAtTime({
animations,
propertyPath: path,
diff --git a/apps/web/src/animation/graph-channels.ts b/apps/web/src/animation/graph-channels.ts
index d6482602..cef1397a 100644
--- a/apps/web/src/animation/graph-channels.ts
+++ b/apps/web/src/animation/graph-channels.ts
@@ -1,21 +1,35 @@
import type {
- AnimationBindingInstance,
AnimationPath,
ElementAnimations,
+ ChannelData,
ScalarAnimationChannel,
ScalarGraphChannel,
ScalarGraphKeyframeContext,
} from "@/animation/types";
+import type { ChannelEasingMode } from "@/params";
+import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
+import { isScalarChannel } from "./interpolation";
export interface EditableScalarChannels {
- binding: AnimationBindingInstance;
+ easingMode: ChannelEasingMode;
channels: ScalarGraphChannel[];
}
function isScalarAnimationChannel(
- channel: ElementAnimations["channels"][string],
+ channel: ChannelData | undefined,
): channel is ScalarAnimationChannel {
- return channel?.kind === "scalar";
+ return isLeafChannelData(channel) && isScalarChannel(channel);
+}
+
+function getEasingModeForChannelData({
+ data,
+}: {
+ data: ChannelData | undefined;
+}): ChannelEasingMode {
+ return isCompositeChannelData(data) &&
+ ["r", "g", "b", "a"].every((componentKey) => componentKey in data)
+ ? "shared"
+ : "independent";
}
export function getEditableScalarChannels({
@@ -25,13 +39,15 @@ export function getEditableScalarChannels({
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): EditableScalarChannels | null {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
+ const data = animations?.[propertyPath];
+ if (!data) {
return null;
}
- const channels = binding.components.flatMap((component) => {
- const channel = animations?.channels[component.channelId];
+ const channelEntries = isLeafChannelData(data)
+ ? [["value", data] as const]
+ : Object.entries(data);
+ const channels = channelEntries.flatMap(([componentKey, channel]) => {
if (!isScalarAnimationChannel(channel)) {
return [];
}
@@ -39,14 +55,13 @@ export function getEditableScalarChannels({
return [
{
propertyPath,
- componentKey: component.key,
- channelId: component.channelId,
+ componentKey,
channel,
} satisfies ScalarGraphChannel,
];
});
- return { binding, channels };
+ return { easingMode: getEasingModeForChannelData({ data }), channels };
}
export function getEditableScalarChannel({
diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts
index d0bfdded..32f84714 100644
--- a/apps/web/src/animation/graphic-param-channel.ts
+++ b/apps/web/src/animation/graphic-param-channel.ts
@@ -49,7 +49,7 @@ export function resolveGraphicParamsAtTime({
for (const param of definitions) {
const path = buildGraphicParamPath({ paramKey: param.key });
- if (!animations?.bindings[path]) {
+ if (!animations?.[path]) {
continue;
}
diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts
index af3b910d..f3d8aca4 100644
--- a/apps/web/src/animation/index.ts
+++ b/apps/web/src/animation/index.ts
@@ -65,10 +65,8 @@ export {
} from "./property-groups";
export {
- type EasingMode,
- getEasingModeForKind,
-} from "./binding-values";
-
-export {
isAnimationPath,
+ isAnimationPropertyPath,
} from "./path";
+
+export type { NumericSpec } from "./types";
diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts
index 6bab66c4..cdb8bcc9 100644
--- a/apps/web/src/animation/interpolation.ts
+++ b/apps/web/src/animation/interpolation.ts
@@ -1,13 +1,15 @@
import type {
AnimationChannel,
AnimationInterpolation,
- AnimationValue,
+ Channel,
DiscreteAnimationChannel,
DiscreteValue,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
} from "@/animation/types";
+import type { ParamValue } from "@/params";
+import { mediaTime } from "@/wasm";
import {
getBezierPoint,
getDefaultLeftHandle,
@@ -63,9 +65,13 @@ function normalizeRightHandle({
return undefined;
}
- const span = Math.max(1, rightKey.time - leftKey.time);
+ const span = mediaTime({
+ ticks: Math.max(1, rightKey.time - leftKey.time),
+ });
return {
- dt: Math.min(span, Math.max(0, handle.dt)),
+ dt: mediaTime({
+ ticks: Math.min(span, Math.max(0, handle.dt)),
+ }),
dv: handle.dv,
};
}
@@ -83,9 +89,13 @@ function normalizeLeftHandle({
return undefined;
}
- const span = Math.max(1, rightKey.time - leftKey.time);
+ const span = mediaTime({
+ ticks: Math.max(1, rightKey.time - leftKey.time),
+ });
return {
- dt: Math.max(-span, Math.min(0, handle.dt)),
+ dt: mediaTime({
+ ticks: Math.max(-span, Math.min(0, handle.dt)),
+ }),
dv: handle.dv,
};
}
@@ -102,7 +112,7 @@ function normalizeScalarKey({
};
}
-function normalizeScalarChannel({
+export function normalizeScalarChannel({
channel,
}: {
channel: ScalarAnimationChannel;
@@ -145,17 +155,11 @@ function normalizeScalarChannel({
};
}
-export function normalizeChannel<TChannel extends AnimationChannel>({
+export function normalizeDiscreteChannel({
channel,
}: {
- channel: TChannel;
-}): TChannel {
- if (channel.kind === "scalar") {
- return normalizeScalarChannel({
- channel,
- }) as TChannel;
- }
-
+ channel: DiscreteAnimationChannel;
+}): DiscreteAnimationChannel {
return {
...channel,
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
@@ -164,7 +168,39 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
rightTime: rightKeyframe.time,
}),
),
- } as TChannel;
+ };
+}
+
+export function isScalarChannel(channel: AnimationChannel): channel is ScalarAnimationChannel {
+ return (
+ "extrapolation" in channel ||
+ channel.keys.some((keyframe) => "segmentToNext" in keyframe)
+ );
+}
+
+export function normalizeChannel({
+ channel,
+}: {
+ channel: ScalarAnimationChannel;
+}): ScalarAnimationChannel;
+export function normalizeChannel({
+ channel,
+}: {
+ channel: DiscreteAnimationChannel;
+}): DiscreteAnimationChannel;
+export function normalizeChannel({
+ channel,
+}: {
+ channel: AnimationChannel;
+}): AnimationChannel;
+export function normalizeChannel({
+ channel,
+}: {
+ channel: AnimationChannel;
+}): AnimationChannel {
+ return isScalarChannel(channel)
+ ? normalizeScalarChannel({ channel })
+ : normalizeDiscreteChannel({ channel });
}
function extrapolateScalarEdge({
@@ -207,7 +243,7 @@ export function getScalarChannelValueAtTime({
time,
fallbackValue,
}: {
- channel: ScalarAnimationChannel | undefined;
+ channel: Channel<number> | undefined;
time: number;
fallbackValue: number;
}): number {
@@ -215,9 +251,7 @@ export function getScalarChannelValueAtTime({
return fallbackValue;
}
- const normalizedChannel = normalizeChannel({
- channel,
- });
+ const normalizedChannel = normalizeScalarChannel({ channel });
const firstKey = normalizedChannel.keys[0];
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
if (!firstKey || !lastKey) {
@@ -319,7 +353,7 @@ export function getDiscreteChannelValueAtTime({
time,
fallbackValue,
}: {
- channel: DiscreteAnimationChannel | undefined;
+ channel: Channel<DiscreteValue> | undefined;
time: number;
fallbackValue: DiscreteValue;
}): DiscreteValue {
@@ -327,9 +361,7 @@ export function getDiscreteChannelValueAtTime({
return fallbackValue;
}
- const normalizedChannel = normalizeChannel({
- channel,
- });
+ const normalizedChannel = normalizeDiscreteChannel({ channel });
let currentValue = fallbackValue;
for (const key of normalizedChannel.keys) {
if (time < key.time) {
@@ -345,16 +377,34 @@ export function getChannelValueAtTime({
time,
fallbackValue,
}: {
+ channel: Channel<number> | undefined;
+ time: number;
+ fallbackValue: number;
+}): number;
+export function getChannelValueAtTime<TValue extends DiscreteValue>({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: DiscreteAnimationChannel | undefined;
+ time: number;
+ fallbackValue: TValue;
+}): TValue;
+export function getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
channel: AnimationChannel | undefined;
time: number;
- fallbackValue: AnimationValue;
-}): AnimationValue {
+ fallbackValue: ParamValue;
+}): ParamValue {
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
- if (channel.kind === "scalar") {
- return typeof fallbackValue === "number"
+ if (typeof fallbackValue === "number") {
+ return isScalarChannel(channel)
? getScalarChannelValueAtTime({
channel,
time,
@@ -368,7 +418,7 @@ export function getChannelValueAtTime({
}
return getDiscreteChannelValueAtTime({
- channel,
+ channel: isScalarChannel(channel) ? undefined : channel,
time,
fallbackValue,
});
diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts
index e65db881..95f7fcf0 100644
--- a/apps/web/src/animation/keyframe-query.ts
+++ b/apps/web/src/animation/keyframe-query.ts
@@ -1,75 +1,75 @@
import type {
- AnimationBindingInstance,
AnimationChannel,
AnimationPath,
+ ChannelData,
ElementAnimations,
ElementKeyframe,
} from "@/animation/types";
+import { formatLinearRgba } from "@/params";
import {
- type AnimationComponentValue,
- composeAnimationValue,
-} from "./binding-values";
+ getChannelEntriesFromData,
+ isAnimationStorageKey,
+} from "./channel-data";
import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
+ isScalarChannel,
} from "./interpolation";
import { isAnimationPath } from "./path";
-function getBindingFallbackValue({
+function getChannelFallbackValue({
channel,
}: {
- channel: ElementAnimations["channels"][string];
+ channel: AnimationChannel;
}) {
- if (!channel || channel.keys.length === 0) {
- return channel?.kind === "discrete" ? false : 0;
+ if (channel.keys.length === 0) {
+ return isScalarChannel(channel) ? 0 : false;
}
return channel.keys[0].value;
}
-interface BindingKeyframeMatch {
+interface ChannelKeyframeMatch {
+ componentKey: string;
componentIndex: number;
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}
-function getBindingKeyframeMatches({
- animations,
- binding,
+function getChannelKeyframeMatches({
+ data,
}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
-}): BindingKeyframeMatch[] {
- return binding.components.flatMap((component, componentIndex) => {
- const channel = animations.channels[component.channelId];
- if (!channel || channel.keys.length === 0) {
- return [];
- }
+ data: ChannelData | undefined;
+}): ChannelKeyframeMatch[] {
+ return getChannelEntriesFromData({ data }).flatMap(
+ ([componentKey, channel], componentIndex) => {
+ if (channel.keys.length === 0) {
+ return [];
+ }
- return channel.keys.map((keyframe) => ({
- componentIndex,
- channel,
- keyframe,
- }));
- });
+ return channel.keys.map((keyframe) => ({
+ componentKey,
+ componentIndex,
+ channel,
+ keyframe,
+ }));
+ },
+ );
}
-function getUniqueBindingKeyframeMatches({
- animations,
- binding,
+function getUniqueChannelKeyframeMatches({
+ data,
}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
-}): BindingKeyframeMatch[] {
- const sortedMatches = getBindingKeyframeMatches({
- animations,
- binding,
+ data: ChannelData | undefined;
+}): ChannelKeyframeMatch[] {
+ const sortedMatches = getChannelKeyframeMatches({
+ data,
}).sort(
(leftMatch, rightMatch) =>
leftMatch.keyframe.time - rightMatch.keyframe.time ||
leftMatch.componentIndex - rightMatch.componentIndex,
);
- const uniqueMatches: BindingKeyframeMatch[] = [];
+ const uniqueMatches: ChannelKeyframeMatch[] = [];
for (const match of sortedMatches) {
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
@@ -92,11 +92,11 @@ function getUniqueBindingKeyframeMatches({
return uniqueMatches;
}
-function getPreferredBindingKeyframeMatch({
+function getPreferredChannelKeyframeMatch({
matches,
}: {
- matches: BindingKeyframeMatch[];
-}): BindingKeyframeMatch | null {
+ matches: ChannelKeyframeMatch[];
+}): ChannelKeyframeMatch | null {
return (
matches.find((match) => match.componentIndex === 0) ??
matches[0] ??
@@ -104,35 +104,67 @@ function getPreferredBindingKeyframeMatch({
);
}
-function getComposedBindingValueAtTime({
- animations,
- binding,
+function getChannelValue({
+ channel,
time,
}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
+ channel: AnimationChannel;
time: number;
}) {
- const componentValues = Object.fromEntries(
- binding.components.map((component) => {
- const channel = animations.channels[component.channelId];
- return [
- component.key,
- getChannelValueAtTime({
- channel,
- time,
- fallbackValue: getBindingFallbackValue({ channel }),
- }),
- ];
- }),
- ) as Record<string, AnimationComponentValue | undefined>;
-
- return composeAnimationValue({
- binding,
- componentValues,
+ const fallbackValue = getChannelFallbackValue({ channel });
+ if (typeof fallbackValue === "number") {
+ return getChannelValueAtTime({
+ channel: isScalarChannel(channel) ? channel : undefined,
+ time,
+ fallbackValue,
+ });
+ }
+ return getChannelValueAtTime({
+ channel: !isScalarChannel(channel) ? channel : undefined,
+ time,
+ fallbackValue,
});
}
+function getComposedChannelDataValueAtTime({
+ data,
+ time,
+}: {
+ data: ChannelData | undefined;
+ time: number;
+}) {
+ const entries = getChannelEntriesFromData({ data });
+ if (entries.length === 0) {
+ return null;
+ }
+ if (entries.length === 1 && entries[0]?.[0] === "value") {
+ return getChannelValue({ channel: entries[0][1], time });
+ }
+
+ const componentValues = Object.fromEntries(
+ entries.map(([componentKey, channel]) => [
+ componentKey,
+ getChannelValue({ channel, time }),
+ ]),
+ );
+ if (
+ typeof componentValues.r === "number" &&
+ typeof componentValues.g === "number" &&
+ typeof componentValues.b === "number" &&
+ typeof componentValues.a === "number"
+ ) {
+ return formatLinearRgba({
+ color: {
+ r: componentValues.r,
+ g: componentValues.g,
+ b: componentValues.b,
+ a: componentValues.a,
+ },
+ });
+ }
+ return null;
+}
+
function getKeyframeInterpolation({
channel,
keyframe,
@@ -140,25 +172,22 @@ function getKeyframeInterpolation({
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}) {
- return channel.kind === "scalar" && "segmentToNext" in keyframe
+ return isScalarChannel(channel) && "segmentToNext" in keyframe
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
: "hold";
}
function toElementKeyframe({
- animations,
- binding,
+ data,
propertyPath,
keyframeMatch,
}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
+ data: ChannelData | undefined;
propertyPath: AnimationPath;
- keyframeMatch: BindingKeyframeMatch;
+ keyframeMatch: ChannelKeyframeMatch;
}): ElementKeyframe | null {
- const value = getComposedBindingValueAtTime({
- animations,
- binding,
+ const value = getComposedChannelDataValueAtTime({
+ data,
time: keyframeMatch.keyframe.time,
});
if (value === null) {
@@ -186,19 +215,19 @@ export function getElementKeyframes({
return [];
}
- return Object.entries(animations.bindings).flatMap(
- ([propertyPath, binding]) => {
- if (!binding || !isAnimationPath(propertyPath)) {
+ return Object.entries(animations).filter(([key]) =>
+ isAnimationStorageKey({ key }),
+ ).flatMap(
+ ([propertyPath, data]) => {
+ if (!data || !isAnimationPath(propertyPath)) {
return [];
}
- return getUniqueBindingKeyframeMatches({
- animations,
- binding,
+ return getUniqueChannelKeyframeMatches({
+ data,
}).flatMap((keyframeMatch) => {
const keyframe = toElementKeyframe({
- animations,
- binding,
+ data,
propertyPath,
keyframeMatch,
});
@@ -219,13 +248,8 @@ export function hasKeyframesForPath({
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),
+ return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some(
+ ([, channel]) => channel.keys.length > 0,
);
}
@@ -238,24 +262,22 @@ export function getKeyframeAtTime({
propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
+ const data = animations?.[propertyPath];
+ if (!data) {
return null;
}
- const keyframeMatch = getPreferredBindingKeyframeMatch({
- matches: getBindingKeyframeMatches({
- animations,
- binding,
- }).filter(({ keyframe }) => keyframe.time === time),
+ const keyframeMatch = getPreferredChannelKeyframeMatch({
+ matches: getChannelKeyframeMatches({ data }).filter(
+ ({ keyframe }) => keyframe.time === time,
+ ),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
- animations,
- binding,
+ data,
propertyPath,
keyframeMatch,
});
@@ -270,24 +292,22 @@ export function getKeyframeById({
propertyPath: AnimationPath;
keyframeId: string;
}): ElementKeyframe | null {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
+ const data = animations?.[propertyPath];
+ if (!data) {
return null;
}
- const keyframeMatch = getPreferredBindingKeyframeMatch({
- matches: getBindingKeyframeMatches({
- animations,
- binding,
- }).filter(({ keyframe }) => keyframe.id === keyframeId),
+ const keyframeMatch = getPreferredChannelKeyframeMatch({
+ matches: getChannelKeyframeMatches({ data }).filter(
+ ({ keyframe }) => keyframe.id === keyframeId,
+ ),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
- animations,
- binding,
+ data,
propertyPath,
keyframeMatch,
});
diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts
index ff0cef8f..c3fd8202 100644
--- a/apps/web/src/animation/keyframes.ts
+++ b/apps/web/src/animation/keyframes.ts
@@ -1,10 +1,8 @@
import type {
- AnimationBindingInstance,
- AnimationBindingKind,
AnimationChannel,
+ ChannelData,
AnimationInterpolation,
AnimationPath,
- AnimationValue,
DiscreteAnimationChannel,
DiscreteAnimationKey,
ElementAnimations,
@@ -13,13 +11,18 @@ import type {
ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/animation/types";
+import type {
+ ChannelComponentDefinition,
+ ParamChannelLayout,
+ ParamValue,
+} from "@/params";
import {
- cloneAnimationBinding,
- createAnimationBinding,
- decomposeAnimationValue,
-} from "./binding-values";
+ getChannelsFromData,
+ isCompositeChannelData,
+ isAnimationStorageKey,
+ isLeafChannelData,
+} from "./channel-data";
import {
- getBezierPoint,
getDefaultLeftHandle,
getDefaultRightHandle,
solveBezierProgressForTime,
@@ -27,8 +30,17 @@ import {
import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
+ isScalarChannel,
normalizeChannel,
+ normalizeDiscreteChannel,
+ normalizeScalarChannel,
} from "./interpolation";
+import {
+ type MediaTime,
+ roundMediaTime,
+ subMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { generateUUID } from "@/utils/id";
function isNearlySameTime({
@@ -49,27 +61,27 @@ function hasChannelKeys({
return Boolean(channel && channel.keys.length > 0);
}
+function hasChannelData({ data }: { data: ChannelData | undefined }): boolean {
+ return getChannelsFromData({ data }).some((channel) =>
+ hasChannelKeys({ channel }),
+ );
+}
+
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 }),
+ const nextAnimations = Object.fromEntries(
+ Object.entries(animations).filter(
+ ([key, data]) => isAnimationStorageKey({ key }) && hasChannelData({ data }),
),
);
- if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) {
+ if (Object.keys(nextAnimations).length === 0) {
return undefined;
}
- return {
- bindings: nextBindings,
- channels: nextChannels,
- };
+ return nextAnimations;
}
function cloneAnimationsState({
@@ -77,34 +89,88 @@ function cloneAnimationsState({
}: {
animations: ElementAnimations | undefined;
}): ElementAnimations {
- return {
- bindings: { ...(animations?.bindings ?? {}) },
- channels: { ...(animations?.channels ?? {}) },
- };
+ return { ...(animations ?? {}) };
}
-function getBindingChannelKind({
- kind,
+function getChannelFromData({
+ data,
+ componentKey,
}: {
- kind: AnimationBindingKind;
-}): AnimationChannel["kind"] {
- return kind === "discrete" ? "discrete" : "scalar";
+ data: ChannelData | undefined;
+ componentKey: string;
+}): AnimationChannel | undefined {
+ if (isLeafChannelData(data)) {
+ return componentKey === "value" ? data : undefined;
+ }
+ if (isCompositeChannelData(data)) {
+ return data[componentKey];
+ }
+ return undefined;
}
-function getPrimaryComponent({
- binding,
+type LayoutComponent = ChannelComponentDefinition<string>;
+
+function getLayoutComponents({
+ channelLayout,
}: {
- binding: AnimationBindingInstance;
-}) {
- return binding.components[0] ?? null;
+ channelLayout: ParamChannelLayout;
+}): LayoutComponent[] {
+ return channelLayout.kind === "leaf"
+ ? [channelLayout.component]
+ : channelLayout.components;
}
-function getPrimaryChannelId({
- binding,
+function getPrimaryComponentKey({
+ channelLayout,
}: {
- binding: AnimationBindingInstance;
-}) {
- return getPrimaryComponent({ binding })?.channelId ?? null;
+ channelLayout: ParamChannelLayout;
+}): string {
+ return getLayoutComponents({ channelLayout })[0]?.key ?? "value";
+}
+
+function getPrimaryChannelFromData({
+ data,
+ channelLayout,
+}: {
+ data: ChannelData | undefined;
+ channelLayout: ParamChannelLayout;
+}): AnimationChannel | undefined {
+ return getChannelFromData({
+ data,
+ componentKey: getPrimaryComponentKey({ channelLayout }),
+ });
+}
+
+function setChannelInData({
+ data,
+ componentKey,
+ channel,
+}: {
+ data: ChannelData | undefined;
+ componentKey: string;
+ channel: AnimationChannel | undefined;
+}): ChannelData | undefined {
+ if (componentKey === "value") {
+ return channel;
+ }
+ const components = isCompositeChannelData(data) ? { ...data } : {};
+ if (channel && hasChannelKeys({ channel })) {
+ components[componentKey] = channel;
+ } else {
+ delete components[componentKey];
+ }
+ return Object.keys(components).length > 0 ? components : undefined;
+}
+
+function getChannelDataEntries({
+ data,
+}: {
+ data: ChannelData | undefined;
+}): Array<[string, AnimationChannel | undefined]> {
+ if (isLeafChannelData(data)) {
+ return [["value", data]];
+ }
+ return isCompositeChannelData(data) ? Object.entries(data) : [];
}
function getScalarSegmentType({
@@ -118,14 +184,14 @@ function getScalarSegmentType({
return interpolation === "bezier" ? "bezier" : "linear";
}
-function getInterpolationForBinding({
- kind,
+function getInterpolationForComponent({
+ component,
interpolation,
}: {
- kind: AnimationBindingKind;
+ component: LayoutComponent;
interpolation: AnimationInterpolation | undefined;
}): AnimationInterpolation {
- if (kind === "discrete") {
+ if (component.valueKind === "discrete") {
return "hold";
}
@@ -137,25 +203,25 @@ function getInterpolationForBinding({
return interpolation;
}
- return "linear";
+ return component.defaultInterpolation;
}
-function createEmptyChannelForBindingKind({
- kind,
+function decomposeChannelLayoutValue({
+ channelLayout,
+ value,
}: {
- kind: AnimationBindingKind;
-}): AnimationChannel {
- if (kind === "discrete") {
- return {
- kind: "discrete",
- keys: [],
- } satisfies DiscreteAnimationChannel;
+ channelLayout: ParamChannelLayout;
+ value: ParamValue;
+}): Record<string, ParamValue> | null {
+ if (channelLayout.kind === "leaf") {
+ return { [channelLayout.component.key]: value };
+ }
+ if (typeof value !== "string") {
+ return null;
}
- return {
- kind: "scalar",
- keys: [],
- } satisfies ScalarAnimationChannel;
+ const components = channelLayout.decompose(value);
+ return components ? { ...components } : null;
}
function createScalarKey({
@@ -166,7 +232,7 @@ function createScalarKey({
previousKey,
}: {
id: string;
- time: number;
+ time: MediaTime;
value: number;
interpolation?: AnimationInterpolation;
previousKey?: ScalarAnimationKey;
@@ -190,7 +256,7 @@ function createDiscreteKey({
value,
}: {
id: string;
- time: number;
+ time: MediaTime;
value: string | boolean;
}): DiscreteAnimationKey {
return {
@@ -200,34 +266,20 @@ function createDiscreteKey({
};
}
-function getBinding({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
-}): AnimationBindingInstance | undefined {
- return animations?.bindings[propertyPath];
+function isDiscreteChannel(
+ channel: AnimationChannel | undefined,
+): channel is DiscreteAnimationChannel {
+ return channel != null && !isScalarChannel(channel);
}
-function getChannelById({
+function getChannelData({
animations,
- channelId,
+ propertyPath,
}: {
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;
+ propertyPath: AnimationPath;
+}): ChannelData | undefined {
+ return animations?.[propertyPath];
}
function getTargetKeyMetadata({
@@ -236,7 +288,7 @@ function getTargetKeyMetadata({
keyframeId,
}: {
channel: AnimationChannel | undefined;
- time: number;
+ time: MediaTime;
keyframeId?: string;
}) {
const normalizedChannel =
@@ -275,12 +327,12 @@ function upsertDiscreteChannelKey({
keyframeId,
}: {
channel: DiscreteAnimationChannel | undefined;
- time: number;
+ time: MediaTime;
value: string | boolean;
keyframeId?: string;
}): DiscreteAnimationChannel {
- const normalizedChannel = normalizeChannel({
- channel: channel ?? { kind: "discrete", keys: [] },
+ const normalizedChannel = normalizeDiscreteChannel({
+ channel: channel ?? { keys: [] },
});
const keys = [...normalizedChannel.keys];
if (keyframeId) {
@@ -291,8 +343,8 @@ function upsertDiscreteChannelKey({
time,
value,
});
- return normalizeChannel({
- channel: { kind: "discrete", keys },
+ return normalizeDiscreteChannel({
+ channel: { keys },
});
}
}
@@ -306,8 +358,8 @@ function upsertDiscreteChannelKey({
time: keys[existingAtTimeIndex].time,
value,
});
- return normalizeChannel({
- channel: { kind: "discrete", keys },
+ return normalizeDiscreteChannel({
+ channel: { keys },
});
}
@@ -318,8 +370,8 @@ function upsertDiscreteChannelKey({
value,
}),
);
- return normalizeChannel({
- channel: { kind: "discrete", keys },
+ return normalizeDiscreteChannel({
+ channel: { keys },
});
}
@@ -332,14 +384,14 @@ function upsertScalarChannelKey({
keyframeId,
}: {
channel: ScalarAnimationChannel | undefined;
- time: number;
+ time: MediaTime;
value: number;
interpolation?: AnimationInterpolation;
defaultInterpolation?: AnimationInterpolation;
keyframeId?: string;
}): ScalarAnimationChannel {
- const normalizedChannel = normalizeChannel({
- channel: channel ?? { kind: "scalar", keys: [] },
+ const normalizedChannel = normalizeScalarChannel({
+ channel: channel ?? { keys: [] },
});
const keys = [...normalizedChannel.keys];
if (keyframeId) {
@@ -358,9 +410,8 @@ function upsertScalarChannelKey({
}
: keys[existingIndex],
});
- return normalizeChannel({
+ return normalizeScalarChannel({
channel: {
- kind: "scalar",
keys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -385,9 +436,8 @@ function upsertScalarChannelKey({
}
: keys[existingAtTimeIndex],
});
- return normalizeChannel({
+ return normalizeScalarChannel({
channel: {
- kind: "scalar",
keys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -402,9 +452,8 @@ function upsertScalarChannelKey({
interpolation: interpolation ?? defaultInterpolation,
}),
);
- return normalizeChannel({
+ return normalizeScalarChannel({
channel: {
- kind: "scalar",
keys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -418,10 +467,11 @@ export function getChannel({
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;
+ const data = getChannelData({ animations, propertyPath });
+ if (isLeafChannelData(data)) {
+ return data;
+ }
+ return getChannelsFromData({ data })[0];
}
export function upsertPathKeyframe({
@@ -431,19 +481,17 @@ export function upsertPathKeyframe({
value,
interpolation,
keyframeId,
- kind,
- defaultInterpolation,
+ channelLayout,
coerceValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
- time: number;
- value: AnimationValue;
+ time: MediaTime;
+ value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
- kind: AnimationBindingKind;
- defaultInterpolation: AnimationInterpolation;
- coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
+ channelLayout: ParamChannelLayout;
+ coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null;
}): ElementAnimations | undefined {
const coercedValue = coerceValue({ value });
if (coercedValue === null) {
@@ -451,71 +499,78 @@ export function upsertPathKeyframe({
}
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 currentData = getChannelData({ animations, propertyPath });
+ const primaryChannel = getPrimaryChannelFromData({
+ data: currentData,
+ channelLayout,
});
const targetKey = getTargetKeyMetadata({
channel: primaryChannel,
time,
keyframeId,
});
- const componentValues = decomposeAnimationValue({
- kind,
+ const componentValues = decomposeChannelLayoutValue({
+ channelLayout,
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];
+ let nextData: ChannelData | undefined = currentData;
+ for (const component of getLayoutComponents({ channelLayout })) {
+ const componentKey = component.key;
+ const nextValue = componentValues[componentKey];
if (nextValue == null) {
continue;
}
- const currentChannel = getChannelById({
- animations,
- channelId: component.channelId,
+ const currentChannel = getChannelFromData({
+ data: currentData,
+ componentKey,
+ });
+ if (component.valueKind === "discrete") {
+ if (typeof nextValue !== "string" && typeof nextValue !== "boolean") {
+ continue;
+ }
+ const nextChannel = upsertDiscreteChannelKey({
+ channel: isDiscreteChannel(currentChannel) ? currentChannel : undefined,
+ time: targetKey.time,
+ value: nextValue,
+ keyframeId: targetKey.id,
+ });
+ nextData = setChannelInData({
+ data: nextData,
+ componentKey,
+ channel: nextChannel,
+ });
+ continue;
+ }
+
+ if (typeof nextValue !== "number") {
+ continue;
+ }
+ const nextChannel = upsertScalarChannelKey({
+ channel:
+ currentChannel != null && isScalarChannel(currentChannel)
+ ? currentChannel
+ : undefined,
+ time: targetKey.time,
+ value: nextValue,
+ interpolation:
+ interpolation != null
+ ? getInterpolationForComponent({ component, interpolation })
+ : undefined,
+ defaultInterpolation: component.defaultInterpolation,
+ keyframeId: targetKey.id,
+ });
+ nextData = setChannelInData({
+ data: nextData,
+ componentKey,
+ channel: nextChannel,
});
- 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,
- });
}
+ nextAnimations[propertyPath] = nextData;
return toAnimation({
animations: nextAnimations,
@@ -530,8 +585,8 @@ export function upsertKeyframe({
keyframeId,
}: {
channel: AnimationChannel | undefined;
- time: number;
- value: AnimationValue;
+ time: MediaTime;
+ value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}): AnimationChannel | undefined {
@@ -539,13 +594,9 @@ export function upsertKeyframe({
return undefined;
}
- if (channel.kind === "discrete") {
- if (typeof value !== "string" && typeof value !== "boolean") {
- return channel;
- }
-
+ if (typeof value === "string" || typeof value === "boolean") {
return upsertDiscreteChannelKey({
- channel,
+ channel: isDiscreteChannel(channel) ? channel : undefined,
time,
value,
keyframeId,
@@ -557,7 +608,7 @@ export function upsertKeyframe({
}
return upsertScalarChannelKey({
- channel,
+ channel: isScalarChannel(channel) ? channel : undefined,
time,
value,
interpolation,
@@ -576,16 +627,30 @@ export function removeKeyframe({
return undefined;
}
+ if (isScalarChannel(channel)) {
+ const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId);
+ if (nextKeys.length === 0) {
+ return undefined;
+ }
+
+ return normalizeScalarChannel({
+ channel: {
+ ...channel,
+ keys: nextKeys,
+ },
+ });
+ }
+
const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId);
if (nextKeys.length === 0) {
return undefined;
}
- return normalizeChannel({
+ return normalizeDiscreteChannel({
channel: {
...channel,
keys: nextKeys,
- } as AnimationChannel,
+ },
});
}
@@ -596,12 +661,34 @@ export function retimeKeyframe({
}: {
channel: AnimationChannel | undefined;
keyframeId: string;
- time: number;
+ time: MediaTime;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
}
+ if (isScalarChannel(channel)) {
+ const keyframeByIdIndex = channel.keys.findIndex(
+ (keyframe) => keyframe.id === keyframeId,
+ );
+ if (keyframeByIdIndex < 0) {
+ return channel;
+ }
+
+ const nextKeys = [...channel.keys];
+ nextKeys[keyframeByIdIndex] = {
+ ...nextKeys[keyframeByIdIndex],
+ time,
+ };
+
+ return normalizeScalarChannel({
+ channel: {
+ ...channel,
+ keys: nextKeys,
+ },
+ });
+ }
+
const keyframeByIdIndex = channel.keys.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
@@ -615,11 +702,11 @@ export function retimeKeyframe({
time,
};
- return normalizeChannel({
+ return normalizeDiscreteChannel({
channel: {
...channel,
keys: nextKeys,
- } as AnimationChannel,
+ },
});
}
@@ -632,26 +719,10 @@ export function setChannel({
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,
+ componentKey: "value",
channel,
});
}
@@ -667,37 +738,14 @@ export function setBindingComponentChannel({
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,
+ nextAnimations[propertyPath] = setChannelInData({
+ data: nextAnimations[propertyPath],
+ componentKey,
+ channel:
+ channel && hasChannelKeys({ channel })
+ ? normalizeChannel({ channel })
+ : undefined,
});
return toAnimation({
animations: nextAnimations,
@@ -717,24 +765,11 @@ export function updateScalarKeyframeCurve({
keyframeId: string;
patch: ScalarCurveKeyframePatch;
}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
- return animations;
- }
-
- const component = getBindingComponent({
- binding,
+ const channel = getChannelFromData({
+ data: getChannelData({ animations, propertyPath }),
componentKey,
});
- if (!component) {
- return animations;
- }
-
- const channel = getChannelById({
- animations,
- channelId: component.channelId,
- });
- if (channel?.kind !== "scalar") {
+ if (!channel || !isScalarChannel(channel)) {
return animations;
}
@@ -764,13 +799,40 @@ export function updateScalarKeyframeCurve({
propertyPath,
componentKey,
channel: {
- kind: "scalar",
keys: nextKeys,
extrapolation: channel.extrapolation,
},
});
}
+function cloneChannelWithKeyIds({
+ channel,
+ keyIdMap,
+}: {
+ channel: AnimationChannel;
+ keyIdMap: Map<string, string>;
+}): AnimationChannel {
+ return isScalarChannel(channel)
+ ? normalizeScalarChannel({
+ channel: {
+ ...channel,
+ keys: channel.keys.map((key) => ({
+ ...key,
+ id: keyIdMap.get(key.id) ?? key.id,
+ })),
+ },
+ })
+ : normalizeDiscreteChannel({
+ channel: {
+ ...channel,
+ keys: channel.keys.map((key) => ({
+ ...key,
+ id: keyIdMap.get(key.id) ?? key.id,
+ })),
+ },
+ });
+}
+
export function cloneAnimations({
animations,
shouldRegenerateKeyframeIds = false,
@@ -783,23 +845,11 @@ export function cloneAnimations({
}
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 }) ?? "",
- });
+ for (const [propertyPath, data] of Object.entries(animations).filter(([key]) =>
+ isAnimationStorageKey({ key }),
+ )) {
+ const channels = getChannelsFromData({ data });
+ const primaryChannel = channels[0];
const keyIdMap = new Map<string, string>();
if (primaryChannel) {
for (const key of primaryChannel.keys) {
@@ -810,24 +860,22 @@ export function cloneAnimations({
}
}
- 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,
+ if (isLeafChannelData(data)) {
+ nextAnimations[propertyPath] = cloneChannelWithKeyIds({
+ channel: data,
+ keyIdMap,
});
+ continue;
+ }
+ if (isCompositeChannelData(data)) {
+ nextAnimations[propertyPath] = Object.fromEntries(
+ Object.entries(data).map(([componentKey, channel]) => [
+ componentKey,
+ channel
+ ? cloneChannelWithKeyIds({ channel, keyIdMap })
+ : undefined,
+ ]),
+ );
}
}
@@ -841,7 +889,7 @@ export function clampAnimationsToDuration({
duration,
}: {
animations: ElementAnimations | undefined;
- duration: number;
+ duration: MediaTime;
}): ElementAnimations | undefined {
if (!animations || duration <= 0) {
return undefined;
@@ -877,7 +925,7 @@ function splitDiscreteChannelAtTime({
shouldIncludeSplitBoundary,
}: {
channel: DiscreteAnimationChannel | undefined;
- splitTime: number;
+ splitTime: MediaTime;
leftBoundaryId: string;
rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean;
@@ -893,7 +941,10 @@ function splitDiscreteChannelAtTime({
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 }));
+ .map((key) => ({
+ ...key,
+ time: subMediaTime({ a: key.time, b: splitTime }),
+ }));
if (shouldIncludeSplitBoundary) {
const hasBoundaryOnLeft = leftKeys.some((key) =>
@@ -913,7 +964,7 @@ function splitDiscreteChannelAtTime({
createDiscreteKey({
id: leftBoundaryId,
time: splitTime,
- value: boundaryValue as string | boolean,
+ value: boundaryValue,
}),
];
}
@@ -921,8 +972,8 @@ function splitDiscreteChannelAtTime({
rightKeys = [
createDiscreteKey({
id: rightBoundaryId,
- time: 0,
- value: boundaryValue as string | boolean,
+ time: ZERO_MEDIA_TIME,
+ value: boundaryValue,
}),
...rightKeys,
];
@@ -931,10 +982,10 @@ function splitDiscreteChannelAtTime({
return {
leftChannel: leftKeys.length
- ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } })
+ ? normalizeChannel({ channel: { keys: leftKeys } })
: undefined,
rightChannel: rightKeys.length
- ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } })
+ ? normalizeChannel({ channel: { keys: rightKeys } })
: undefined,
};
}
@@ -947,7 +998,7 @@ function splitScalarChannelAtTime({
shouldIncludeSplitBoundary,
}: {
channel: ScalarAnimationChannel | undefined;
- splitTime: number;
+ splitTime: MediaTime;
leftBoundaryId: string;
rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean;
@@ -963,7 +1014,10 @@ function splitScalarChannelAtTime({
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 }));
+ .map((key) => ({
+ ...key,
+ time: subMediaTime({ a: key.time, b: splitTime }),
+ }));
const hasBoundaryOnLeft = leftKeys.some((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
@@ -976,7 +1030,6 @@ function splitScalarChannelAtTime({
leftChannel: leftKeys.length
? normalizeChannel({
channel: {
- kind: "scalar",
keys: leftKeys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -985,7 +1038,6 @@ function splitScalarChannelAtTime({
rightChannel: rightKeys.length
? normalizeChannel({
channel: {
- kind: "scalar",
keys: rightKeys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -1010,7 +1062,7 @@ function splitScalarChannelAtTime({
channel: normalizedChannel,
time: splitTime,
fallbackValue: leftKey.value,
- }) as number;
+ });
if (leftKey.segmentToNext === "bezier") {
const rightHandle =
@@ -1043,7 +1095,7 @@ function splitScalarChannelAtTime({
{
...leftKey,
rightHandle: {
- dt: q0.x - p0.x,
+ dt: roundMediaTime({ time: q0.x - p0.x }),
dv: q0.y - p0.y,
},
},
@@ -1052,7 +1104,7 @@ function splitScalarChannelAtTime({
time: splitTime,
value: boundaryValue,
leftHandle: {
- dt: r0.x - splitPoint.x,
+ dt: roundMediaTime({ time: r0.x - splitPoint.x }),
dv: r0.y - splitPoint.y,
},
segmentToNext: leftKey.segmentToNext,
@@ -1062,10 +1114,10 @@ function splitScalarChannelAtTime({
rightKeys = [
{
id: rightBoundaryId,
- time: 0,
+ time: ZERO_MEDIA_TIME,
value: boundaryValue,
rightHandle: {
- dt: r1.x - splitPoint.x,
+ dt: roundMediaTime({ time: r1.x - splitPoint.x }),
dv: r1.y - splitPoint.y,
},
segmentToNext: "bezier",
@@ -1073,9 +1125,9 @@ function splitScalarChannelAtTime({
},
{
...rightKey,
- time: rightKey.time - splitTime,
+ time: subMediaTime({ a: rightKey.time, b: splitTime }),
leftHandle: {
- dt: q2.x - p3.x,
+ dt: roundMediaTime({ time: q2.x - p3.x }),
dv: q2.y - p3.y,
},
},
@@ -1083,7 +1135,7 @@ function splitScalarChannelAtTime({
.filter((key) => key.time > rightKey.time)
.map((key) => ({
...key,
- time: key.time - splitTime,
+ time: subMediaTime({ a: key.time, b: splitTime }),
})),
];
} else {
@@ -1099,7 +1151,7 @@ function splitScalarChannelAtTime({
rightKeys = [
createScalarKey({
id: rightBoundaryId,
- time: 0,
+ time: ZERO_MEDIA_TIME,
value: boundaryValue,
interpolation: getScalarSegmentInterpolation({
segment: leftKey.segmentToNext,
@@ -1112,14 +1164,12 @@ function splitScalarChannelAtTime({
return {
leftChannel: normalizeChannel({
channel: {
- kind: "scalar",
keys: leftKeys,
extrapolation: normalizedChannel.extrapolation,
},
}),
rightChannel: normalizeChannel({
channel: {
- kind: "scalar",
keys: rightKeys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -1131,7 +1181,6 @@ function splitScalarChannelAtTime({
leftChannel: leftKeys.length
? normalizeChannel({
channel: {
- kind: "scalar",
keys: leftKeys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -1140,7 +1189,6 @@ function splitScalarChannelAtTime({
rightChannel: rightKeys.length
? normalizeChannel({
channel: {
- kind: "scalar",
keys: rightKeys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -1149,13 +1197,44 @@ function splitScalarChannelAtTime({
};
}
+function splitChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+}: {
+ channel: AnimationChannel | undefined;
+ splitTime: MediaTime;
+ leftBoundaryId: string;
+ rightBoundaryId: string;
+ shouldIncludeSplitBoundary: boolean;
+}) {
+ return channel != null && !isScalarChannel(channel)
+ ? splitDiscreteChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+ })
+ : splitScalarChannelAtTime({
+ channel:
+ channel != null && isScalarChannel(channel) ? channel : undefined,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+ });
+}
+
export function splitAnimationsAtTime({
animations,
splitTime,
shouldIncludeSplitBoundary = true,
}: {
animations: ElementAnimations | undefined;
- splitTime: number;
+ splitTime: MediaTime;
shouldIncludeSplitBoundary?: boolean;
}): {
leftAnimations: ElementAnimations | undefined;
@@ -1168,55 +1247,39 @@ export function splitAnimationsAtTime({
const leftAnimations = cloneAnimationsState({ animations: undefined });
const rightAnimations = cloneAnimationsState({ animations: undefined });
- for (const [propertyPath, binding] of Object.entries(animations.bindings)) {
- if (!binding) {
+ for (const [propertyPath, data] of Object.entries(animations).filter(([key]) =>
+ isAnimationStorageKey({ key }),
+ )) {
+ if (!data) {
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,
+ for (const [componentKey, channel] of getChannelDataEntries({ data })) {
+ const splitResult = splitChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
});
- 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;
+ leftAnimations[propertyPath] = setChannelInData({
+ data: leftAnimations[propertyPath],
+ componentKey,
+ channel: splitResult.leftChannel,
+ });
}
if (splitResult.rightChannel) {
- rightAnimations.channels[component.channelId] = splitResult.rightChannel;
- hasRightKeys = true;
+ rightAnimations[propertyPath] = setChannelInData({
+ data: rightAnimations[propertyPath],
+ componentKey,
+ channel: splitResult.rightChannel,
+ });
}
}
-
- if (hasLeftKeys) {
- leftAnimations.bindings[propertyPath] = leftBinding;
- }
- if (hasRightKeys) {
- rightAnimations.bindings[propertyPath] = rightBinding;
- }
}
return {
@@ -1234,28 +1297,24 @@ export function removeElementKeyframe({
propertyPath: AnimationPath;
keyframeId: string;
}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
+ const data = getChannelData({ animations, propertyPath });
+ if (!data) {
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];
+ if (isLeafChannelData(data)) {
+ nextAnimations[propertyPath] = removeKeyframe({ channel: data, keyframeId });
+ } else if (isCompositeChannelData(data)) {
+ let nextData: ChannelData | undefined = data;
+ for (const [componentKey, channel] of Object.entries(data)) {
+ nextData = setChannelInData({
+ data: nextData,
+ componentKey,
+ channel: removeKeyframe({ channel, keyframeId }),
+ });
}
+ nextAnimations[propertyPath] = nextData;
}
return toAnimation({
animations: nextAnimations,
@@ -1271,20 +1330,30 @@ export function retimeElementKeyframe({
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
keyframeId: string;
- time: number;
+ time: MediaTime;
}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
+ const data = getChannelData({ animations, propertyPath });
+ if (!data) {
return animations;
}
const nextAnimations = cloneAnimationsState({ animations });
- for (const component of binding.components) {
- nextAnimations.channels[component.channelId] = retimeKeyframe({
- channel: nextAnimations.channels[component.channelId],
+ if (isLeafChannelData(data)) {
+ nextAnimations[propertyPath] = retimeKeyframe({
+ channel: data,
keyframeId,
time,
});
+ } else if (isCompositeChannelData(data)) {
+ let nextData: ChannelData | undefined = data;
+ for (const [componentKey, channel] of Object.entries(data)) {
+ nextData = setChannelInData({
+ data: nextData,
+ componentKey,
+ channel: retimeKeyframe({ channel, keyframeId, time }),
+ });
+ }
+ nextAnimations[propertyPath] = nextData;
}
return toAnimation({
animations: nextAnimations,
diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts
index 19685cea..331dcbaa 100644
--- a/apps/web/src/animation/resolve.ts
+++ b/apps/web/src/animation/resolve.ts
@@ -1,15 +1,13 @@
import type {
AnimationPath,
- AnimationValueForPath,
ElementAnimations,
} from "@/animation/types";
-import {
- type AnimationComponentValue,
- composeAnimationValue,
- decomposeAnimationValue,
-} from "./binding-values";
+import { formatLinearRgba, parseColorToLinearRgba } from "@/params";
+import type { ParamValue } from "@/params";
+import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
import {
getChannelValueAtTime,
+ isScalarChannel,
} from "./interpolation";
export function getElementLocalTime({
@@ -33,47 +31,123 @@ export function getElementLocalTime({
return localTime;
}
-export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
+export function resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime,
+ fallbackValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ localTime: number;
+ fallbackValue: number;
+}): number;
+export function resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime,
+ fallbackValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ localTime: number;
+ fallbackValue: string;
+}): string;
+export function resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime,
+ fallbackValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ localTime: number;
+ fallbackValue: boolean;
+}): boolean;
+export function resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime,
+ fallbackValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ localTime: number;
+ fallbackValue: ParamValue;
+}): ParamValue;
+export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
- propertyPath: TPath;
+ propertyPath: AnimationPath;
localTime: number;
- fallbackValue: AnimationValueForPath<TPath>;
-}): AnimationValueForPath<TPath> {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
+ fallbackValue: ParamValue;
+}): ParamValue {
+ const data = animations?.[propertyPath];
+ if (!data) {
+ return fallbackValue;
+ }
+ if (isLeafChannelData(data)) {
+ if (typeof fallbackValue === "number") {
+ return getChannelValueAtTime({
+ channel: isScalarChannel(data) ? data : undefined,
+ time: localTime,
+ fallbackValue,
+ });
+ }
+ if (typeof fallbackValue === "string" || typeof fallbackValue === "boolean") {
+ return getChannelValueAtTime({
+ channel: !isScalarChannel(data) ? data : undefined,
+ time: localTime,
+ fallbackValue,
+ });
+ }
+ return fallbackValue;
+ }
+ if (!isCompositeChannelData(data)) {
return fallbackValue;
}
- const fallbackComponents = decomposeAnimationValue({
- kind: binding.kind,
- value: fallbackValue,
- });
- if (!fallbackComponents) {
+ if (
+ typeof fallbackValue !== "string" ||
+ !("r" in data) ||
+ !("g" in data) ||
+ !("b" in data) ||
+ !("a" in data)
+ ) {
return fallbackValue;
}
- const componentValues = Object.fromEntries(
- binding.components.map((component) => {
- const channel = animations?.channels[component.channelId];
- return [
- component.key,
- getChannelValueAtTime({
- channel,
- time: localTime,
- fallbackValue:
- fallbackComponents[component.key] ??
- (channel?.kind === "discrete" ? false : 0),
- }),
- ];
- }),
- ) as Record<string, AnimationComponentValue | undefined>;
- return (composeAnimationValue({
- binding,
- componentValues,
- }) ?? fallbackValue) as AnimationValueForPath<TPath>;
+ const fallbackComponents = parseColorToLinearRgba({ color: fallbackValue });
+ if (fallbackComponents === null) {
+ return fallbackValue;
+ }
+
+ return formatLinearRgba({
+ color: {
+ r: getChannelValueAtTime({
+ channel: data.r && isScalarChannel(data.r) ? data.r : undefined,
+ time: localTime,
+ fallbackValue: fallbackComponents.r,
+ }),
+ g: getChannelValueAtTime({
+ channel: data.g && isScalarChannel(data.g) ? data.g : undefined,
+ time: localTime,
+ fallbackValue: fallbackComponents.g,
+ }),
+ b: getChannelValueAtTime({
+ channel: data.b && isScalarChannel(data.b) ? data.b : undefined,
+ time: localTime,
+ fallbackValue: fallbackComponents.b,
+ }),
+ a: getChannelValueAtTime({
+ channel: data.a && isScalarChannel(data.a) ? data.a : undefined,
+ time: localTime,
+ fallbackValue: fallbackComponents.a,
+ }),
+ },
+ });
}
diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts
index 7e241994..9b5bd0c7 100644
--- a/apps/web/src/animation/types.ts
+++ b/apps/web/src/animation/types.ts
@@ -1,3 +1,6 @@
+import type { MediaTime } from "@/wasm";
+import type { ParamValue } from "@/params";
+
export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX",
"transform.positionY",
@@ -18,10 +21,7 @@ export const ANIMATION_PROPERTY_PATHS = [
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
export type GraphicParamPath = `params.${string}`;
export type EffectParamPath = `effects.${string}.params.${string}`;
-export type AnimationPath =
- | AnimationPropertyPath
- | GraphicParamPath
- | EffectParamPath;
+export type AnimationPath = string;
export const ANIMATION_PROPERTY_GROUPS = {
"transform.scale": ["transform.scaleX", "transform.scaleY"],
@@ -29,44 +29,21 @@ export const ANIMATION_PROPERTY_GROUPS = {
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 = number | string | boolean;
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
-export type AnimationValueForPath<TPath extends AnimationPath> =
- TPath extends AnimationPropertyPath
- ? AnimationPropertyValueMap[TPath]
- : TPath extends GraphicParamPath | EffectParamPath
- ? DynamicAnimationPathValue
- : never;
-export type AnimationNumericPropertyPath = {
- [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
-}[AnimationPropertyPath];
-export type AnimationColorPropertyPath = {
- [K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
-}[AnimationPropertyPath];
+export type AnimationColorPropertyPath = Extract<
+ AnimationPropertyPath,
+ "color" | "background.color"
+>;
+export type AnimationNumericPropertyPath = Exclude<
+ AnimationPropertyPath,
+ AnimationColorPropertyPath
+>;
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
export type DiscreteKeyframeInterpolation = "hold";
@@ -74,20 +51,18 @@ 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;
+ dt: MediaTime;
dv: number;
}
-interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
+interface BaseAnimationKeyframe<TValue extends ParamValue> {
id: string;
- time: number; // relative to element start time
+ time: MediaTime; // relative to element start time
value: TValue;
}
@@ -98,13 +73,16 @@ export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
tangentMode: TangentMode;
}
-export interface DiscreteAnimationKey
- extends BaseAnimationKeyframe<DiscreteValue> {}
+export type DiscreteAnimationKey = BaseAnimationKeyframe<DiscreteValue>;
-export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
+export type Keyframe<TValue extends ParamValue = ParamValue> =
+ TValue extends number
+ ? ScalarAnimationKey
+ : TValue extends DiscreteValue
+ ? DiscreteAnimationKey
+ : never;
-export interface ScalarAnimationChannel {
- kind: "scalar";
+export interface ScalarChannel {
keys: ScalarAnimationKey[];
extrapolation?: {
before: ChannelExtrapolationMode;
@@ -112,72 +90,26 @@ export interface ScalarAnimationChannel {
};
}
-export interface DiscreteAnimationChannel {
- kind: "discrete";
+export interface DiscreteChannel {
keys: DiscreteAnimationKey[];
}
-export type AnimationChannel =
- | ScalarAnimationChannel
- | DiscreteAnimationChannel;
-
-export type ElementAnimationChannelMap = Record<
- string,
- AnimationChannel | undefined
->;
-
-export interface AnimationBindingComponent<TKey extends string = string> {
- key: TKey;
- channelId: string;
-}
-
-interface BaseAnimationBinding<
- TKind extends AnimationBindingKind,
- TComponentKey extends string,
-> {
- path: AnimationPath;
- kind: TKind;
- components: AnimationBindingComponent<TComponentKey>[];
-}
-
-export interface NumberAnimationBinding
- extends BaseAnimationBinding<"number", "value"> {}
-
-export interface Vector2AnimationBinding
- extends BaseAnimationBinding<"vector2", "x" | "y"> {}
-
-export interface ColorAnimationBinding
- extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
- colorSpace: "srgb-linear";
-}
-
-export interface DiscreteAnimationBinding
- extends BaseAnimationBinding<"discrete", "value"> {}
-
-export type AnimationBindingInstance =
- | NumberAnimationBinding
- | Vector2AnimationBinding
- | ColorAnimationBinding
- | DiscreteAnimationBinding;
-
-export interface AnimationBindingByKind {
- number: NumberAnimationBinding;
- vector2: Vector2AnimationBinding;
- color: ColorAnimationBinding;
- discrete: DiscreteAnimationBinding;
-}
+export type Channel<TValue extends ParamValue = ParamValue> =
+ TValue extends number
+ ? ScalarChannel
+ : TValue extends DiscreteValue
+ ? DiscreteChannel
+ : never;
-export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
- AnimationBindingByKind[TKind];
+export type ScalarAnimationChannel = Channel<number>;
+export type DiscreteAnimationChannel = Channel<DiscreteValue>;
+export type AnimationChannel = Channel;
-export type ElementAnimationBindingMap = Record<
- string,
- AnimationBindingInstance | undefined
->;
+export type CompositeChannelData = Record<string, AnimationChannel | undefined>;
+export type ChannelData = AnimationChannel | CompositeChannelData;
export interface ElementAnimations {
- bindings: ElementAnimationBindingMap;
- channels: ElementAnimationChannelMap;
+ [propertyPath: AnimationPath]: ChannelData | undefined;
}
export type NormalizedCubicBezier = [number, number, number, number];
@@ -185,7 +117,6 @@ export type NormalizedCubicBezier = [number, number, number, number];
export interface ScalarGraphChannelTarget {
propertyPath: AnimationPath;
componentKey: string;
- channelId: string;
}
export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
@@ -213,8 +144,8 @@ export interface ScalarCurveKeyframePatch {
export interface ElementKeyframe {
propertyPath: AnimationPath;
id: string;
- time: number;
- value: AnimationValue;
+ time: MediaTime;
+ value: ParamValue;
interpolation: AnimationInterpolation;
}
diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx
index 94e3f3e1..9728cbc7 100644
--- a/apps/web/src/app/brand/page.tsx
+++ b/apps/web/src/app/brand/page.tsx
@@ -190,7 +190,7 @@ export default function BrandPage() {
</div>
<div className="flex flex-col gap-3">
- <h2 className="font-semibold text-lg">What's not allowed</h2>
+ <h2 className="font-semibold text-lg">What&apos;s not allowed</h2>
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
{[
"Using OpenCut in the name of your product, service, or domain.",
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index b3d40a37..680dfff4 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -130,8 +130,14 @@ function EditorLayout() {
direction="vertical"
className="size-full gap-[0.18rem]"
onLayout={(sizes) => {
- setPanel("mainContent", sizes[0] ?? panels.mainContent);
- setPanel("timeline", sizes[1] ?? panels.timeline);
+ setPanel({
+ panel: "mainContent",
+ size: sizes[0] ?? panels.mainContent,
+ });
+ setPanel({
+ panel: "timeline",
+ size: sizes[1] ?? panels.timeline,
+ });
}}
>
<ResizablePanel
@@ -144,9 +150,12 @@ function EditorLayout() {
direction="horizontal"
className="size-full gap-[0.19rem] px-3"
onLayout={(sizes) => {
- setPanel("tools", sizes[0] ?? panels.tools);
- setPanel("preview", sizes[1] ?? panels.preview);
- setPanel("properties", sizes[2] ?? panels.properties);
+ setPanel({ panel: "tools", size: sizes[0] ?? panels.tools });
+ setPanel({ panel: "preview", size: sizes[1] ?? panels.preview });
+ setPanel({
+ panel: "properties",
+ size: sizes[2] ?? panels.properties,
+ });
}}
>
<ResizablePanel
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx
index 6362146e..17775147 100644
--- a/apps/web/src/app/privacy/page.tsx
+++ b/apps/web/src/app/privacy/page.tsx
@@ -57,7 +57,10 @@ export default function PrivacyPage() {
content is tracked
</li>
<li>You can clear local data from your browser at any time</li>
- <li>We don't sell or share your data with anyone (we don't even have it)</li>
+ <li>
+ We don&apos;t sell or share your data with anyone (we don&apos;t
+ even have it)
+ </li>
</ol>
<p className="mt-4">
Questions? Email us at{" "}
@@ -172,7 +175,7 @@ export default function PrivacyPage() {
<a
href={SOCIAL_LINKS.github}
target="_blank"
- rel="noopener"
+ rel="noopener noreferrer"
className="text-primary hover:underline"
>
GitHub
@@ -189,7 +192,7 @@ export default function PrivacyPage() {
<a
href={`${SOCIAL_LINKS.github}/issues`}
target="_blank"
- rel="noopener"
+ rel="noopener noreferrer"
className="text-primary hover:underline"
>
GitHub repository
@@ -205,7 +208,7 @@ export default function PrivacyPage() {
<a
href={SOCIAL_LINKS.x}
target="_blank"
- rel="noopener"
+ rel="noopener noreferrer"
className="text-primary hover:underline"
>
X (Twitter)
diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx
index 0845892c..5f2e0420 100644
--- a/apps/web/src/app/terms/page.tsx
+++ b/apps/web/src/app/terms/page.tsx
@@ -51,9 +51,13 @@ export default function TermsPage() {
Free for personal and commercial use with no watermarks or
restrictions
</li>
- <li>You're responsible for how you use it - don't break the law</li>
<li>
- Service provided "as is" - we can't guarantee perfect uptime
+ You&apos;re responsible for how you use it - don&apos;t break
+ the law
+ </li>
+ <li>
+ Service provided &quot;as is&quot; - we can&apos;t guarantee
+ perfect uptime
</li>
<li>
Open source means you can review our code and self-host if
@@ -109,8 +113,8 @@ export default function TermsPage() {
</li>
</ul>
<p>
- You're responsible for how you use OpenCut and the content you create.
- Don't use it for anything illegal in your jurisdiction.
+ You&apos;re responsible for how you use OpenCut and the content you
+ create. Don&apos;t use it for anything illegal in your jurisdiction.
</p>
</section>
@@ -127,8 +131,8 @@ export default function TermsPage() {
<h2 className="text-2xl font-semibold">Service</h2>
<p>
OpenCut does not currently require an account. The service is provided
- "as is" without warranties. While we strive for reliability, we can't
- guarantee uninterrupted service.
+ &quot;as is&quot; without warranties. While we strive for
+ reliability, we can&apos;t guarantee uninterrupted service.
</p>
</section>
@@ -146,7 +150,7 @@ export default function TermsPage() {
<a
href={SOCIAL_LINKS.github}
target="_blank"
- rel="noopener"
+ rel="noopener noreferrer"
className="text-primary hover:underline"
>
GitHub
@@ -161,12 +165,12 @@ export default function TermsPage() {
OpenCut is provided free of charge. To the extent permitted by law:
</p>
<ul className="list-disc space-y-2 pl-6">
- <li>We're not liable for any loss of data or content</li>
+ <li>We&apos;re not liable for any loss of data or content</li>
<li>
Projects are stored in your browser and may be lost if you clear
browser data
</li>
- <li>We're not responsible for how you use the service</li>
+ <li>We&apos;re not responsible for how you use the service</li>
<li>Our liability is limited to the maximum extent allowed by law</li>
</ul>
<p>
@@ -180,7 +184,7 @@ export default function TermsPage() {
<h2 className="text-2xl font-semibold">Service Changes</h2>
<p>We may update OpenCut and these terms:</p>
<ul className="list-disc space-y-2 pl-6">
- <li>We'll notify you of significant changes to these terms</li>
+ <li>We&apos;ll notify you of significant changes to these terms</li>
<li>Continued use means you accept any updates</li>
<li>You can always self-host an older version if you prefer</li>
<li>Major changes will be discussed with the community on GitHub</li>
@@ -203,7 +207,7 @@ export default function TermsPage() {
<a
href={`${SOCIAL_LINKS.github}/issues`}
target="_blank"
- rel="noopener"
+ rel="noopener noreferrer"
className="text-primary hover:underline"
>
GitHub repository
@@ -219,7 +223,7 @@ export default function TermsPage() {
<a
href={SOCIAL_LINKS.x}
target="_blank"
- rel="noopener"
+ rel="noopener noreferrer"
className="text-primary hover:underline"
>
X (Twitter)
diff --git a/apps/web/src/clipboard/handlers/elements.ts b/apps/web/src/clipboard/handlers/elements.ts
index b2f5f2c3..2297b493 100644
--- a/apps/web/src/clipboard/handlers/elements.ts
+++ b/apps/web/src/clipboard/handlers/elements.ts
@@ -35,7 +35,7 @@ export const ElementsClipboardHandler = {
};
},
- paste(entry, context) {
+ paste({ entry, context }) {
if (entry.items.length === 0) {
return null;
}
diff --git a/apps/web/src/clipboard/handlers/index.ts b/apps/web/src/clipboard/handlers/index.ts
index 29d4d1d0..a042c3dd 100644
--- a/apps/web/src/clipboard/handlers/index.ts
+++ b/apps/web/src/clipboard/handlers/index.ts
@@ -45,5 +45,5 @@ export function buildPasteClipboardCommand({
const handler = clipboardHandlers[entry.type] as ClipboardHandler<
typeof entry.type
>;
- return handler.paste(entry as never, context);
+ return handler.paste({ entry: entry as never, context });
}
diff --git a/apps/web/src/clipboard/handlers/keyframes.ts b/apps/web/src/clipboard/handlers/keyframes.ts
index ad156e12..dde8384f 100644
--- a/apps/web/src/clipboard/handlers/keyframes.ts
+++ b/apps/web/src/clipboard/handlers/keyframes.ts
@@ -1,4 +1,6 @@
import { getKeyframeById } from "@/animation";
+import { getChannelEntriesFromData } from "@/animation/channel-data";
+import { isScalarChannel } from "@/animation/interpolation";
import type { SelectedKeyframeRef } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
import { PasteKeyframesCommand } from "@/commands/timeline";
@@ -7,6 +9,7 @@ import type {
KeyframeClipboardCurvePatch,
KeyframeClipboardItem,
} from "../types";
+import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm";
function resolveSingleSourceElement({
selectedKeyframes,
@@ -40,14 +43,13 @@ function getCurvePatches({
propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string;
}): KeyframeClipboardCurvePatch[] {
- const binding = element.animations?.bindings[propertyPath];
- if (!binding) {
+ const data = element.animations?.[propertyPath];
+ if (!data) {
return [];
}
- return binding.components.flatMap((component) => {
- const channel = element.animations?.channels[component.channelId];
- if (channel?.kind !== "scalar") {
+ return getChannelEntriesFromData({ data }).flatMap(([componentKey, channel]) => {
+ if (!channel || !isScalarChannel(channel)) {
return [];
}
@@ -60,7 +62,7 @@ function getCurvePatches({
return [
{
- componentKey: component.key,
+ componentKey,
patch: {
leftHandle: keyframe.leftHandle ?? null,
rightHandle: keyframe.rightHandle ?? null,
@@ -80,7 +82,7 @@ function buildClipboardItem({
element: TimelineElement;
propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string;
-}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null {
+}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null {
const keyframe = getKeyframeById({
animations: element.animations,
propertyPath,
@@ -136,10 +138,11 @@ export const KeyframesClipboardHandler = {
}
const minTime = Math.min(...rawItems.map((item) => item.time));
+ const minTimeMedia = roundMediaTime({ time: minTime });
const items = rawItems
.map(({ time, ...item }) => ({
...item,
- timeOffset: time - minTime,
+ timeOffset: subMediaTime({ a: time, b: minTimeMedia }),
}))
.sort(
(left, right) =>
@@ -154,7 +157,10 @@ export const KeyframesClipboardHandler = {
};
},
- paste(entry, { selectedElements, time }) {
+ paste({
+ entry,
+ context: { selectedElements, time },
+ }) {
const targetElement = selectedElements[0];
if (!targetElement || entry.items.length === 0) {
return null;
diff --git a/apps/web/src/clipboard/types.ts b/apps/web/src/clipboard/types.ts
index 0d249c9d..24ca9105 100644
--- a/apps/web/src/clipboard/types.ts
+++ b/apps/web/src/clipboard/types.ts
@@ -2,16 +2,17 @@ import type { EditorCore } from "@/core";
import type {
AnimationInterpolation,
AnimationPath,
- AnimationValue,
ScalarCurveKeyframePatch,
SelectedKeyframeRef,
} from "@/animation/types";
+import type { ParamValue } from "@/params";
import type { Command } from "@/commands/base-command";
import type {
CreateTimelineElement,
ElementRef,
TrackType,
} from "@/timeline";
+import type { MediaTime } from "@/wasm";
export interface ElementClipboardItem {
trackId: string;
@@ -26,8 +27,8 @@ export interface KeyframeClipboardCurvePatch {
export interface KeyframeClipboardItem {
propertyPath: AnimationPath;
- timeOffset: number;
- value: AnimationValue;
+ timeOffset: MediaTime;
+ value: ParamValue;
interpolation: AnimationInterpolation;
curvePatches: KeyframeClipboardCurvePatch[];
}
@@ -61,17 +62,17 @@ export interface PasteContext {
editor: EditorCore;
selectedElements: ElementRef[];
selectedKeyframes: SelectedKeyframeRef[];
- time: number;
+ time: MediaTime;
}
export interface ClipboardHandler<TType extends ClipboardEntryType> {
type: TType;
canCopy(context: CopyContext): boolean;
copy(context: CopyContext): ClipboardEntryByType[TType] | null;
- paste(
- entry: ClipboardEntryByType[TType],
- context: PasteContext,
- ): Command | null;
+ paste(args: {
+ entry: ClipboardEntryByType[TType];
+ context: PasteContext;
+ }): Command | null;
}
export type ClipboardHandlerMap = {
diff --git a/apps/web/src/commands/media/add-media-asset.ts b/apps/web/src/commands/media/add-media-asset.ts
index d22241a2..629bc3ee 100644
--- a/apps/web/src/commands/media/add-media-asset.ts
+++ b/apps/web/src/commands/media/add-media-asset.ts
@@ -16,14 +16,22 @@ export class AddMediaAssetCommand extends Command {
private previousProjectFps: FrameRate | null = null;
private appliedProjectFps: FrameRate | null = null;
- constructor(
- private projectId: string,
- private asset: Omit<MediaAsset, "id">,
- ) {
+ constructor({
+ projectId,
+ asset,
+ }: {
+ projectId: string;
+ asset: Omit<MediaAsset, "id">;
+ }) {
super();
+ this.projectId = projectId;
+ this.asset = asset;
this.assetId = generateUUID();
}
+ private projectId: string;
+ private asset: Omit<MediaAsset, "id">;
+
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedAssets = [...editor.media.getAssets()];
@@ -117,7 +125,14 @@ export class AddMediaAssetCommand extends Command {
const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return;
- if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
+ if (
+ !this.appliedProjectFps ||
+ !frameRatesEqual({
+ a: activeProject.settings.fps,
+ b: this.appliedProjectFps,
+ })
+ )
+ return;
const highestRemainingVideoFps = getHighestImportedVideoFps({
mediaAssets: editor.media.getAssets(),
diff --git a/apps/web/src/commands/media/remove-media-asset.ts b/apps/web/src/commands/media/remove-media-asset.ts
index 8da62181..38ff79fe 100644
--- a/apps/web/src/commands/media/remove-media-asset.ts
+++ b/apps/web/src/commands/media/remove-media-asset.ts
@@ -13,13 +13,21 @@ export class RemoveMediaAssetCommand extends Command {
private savedTracks: SceneTracks | null = null;
private removedAsset: MediaAsset | null = null;
- constructor(
- private projectId: string,
- private assetId: string,
- ) {
+ constructor({
+ projectId,
+ assetId,
+ }: {
+ projectId: string;
+ assetId: string;
+ }) {
super();
+ this.projectId = projectId;
+ this.assetId = assetId;
}
+ private projectId: string;
+ private assetId: string;
+
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const assets = editor.media.getAssets();
diff --git a/apps/web/src/commands/scene/create-scene.ts b/apps/web/src/commands/scene/create-scene.ts
index b554fe90..3776467d 100644
--- a/apps/web/src/commands/scene/create-scene.ts
+++ b/apps/web/src/commands/scene/create-scene.ts
@@ -7,13 +7,21 @@ export class CreateSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private createdScene: TScene | null = null;
- constructor(
- private name: string,
- private isMain: boolean = false,
- ) {
+ constructor({
+ name,
+ isMain = false,
+ }: {
+ name: string;
+ isMain?: boolean;
+ }) {
super();
+ this.name = name;
+ this.isMain = isMain;
}
+ private name: string;
+ private isMain: boolean;
+
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedScenes = [...editor.scenes.getScenes()];
diff --git a/apps/web/src/commands/scene/move-bookmark.ts b/apps/web/src/commands/scene/move-bookmark.ts
index a1624eb0..45b7bda5 100644
--- a/apps/web/src/commands/scene/move-bookmark.ts
+++ b/apps/web/src/commands/scene/move-bookmark.ts
@@ -3,17 +3,26 @@ import { EditorCore } from "@/core";
import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/timeline/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
+import type { MediaTime } from "@/wasm";
export class MoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
- constructor(
- private fromTime: number,
- private toTime: number,
- ) {
+ constructor({
+ fromTime,
+ toTime,
+ }: {
+ fromTime: MediaTime;
+ toTime: MediaTime;
+ }) {
super();
+ this.fromTime = fromTime;
+ this.toTime = toTime;
}
+ private fromTime: MediaTime;
+ private toTime: MediaTime;
+
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene();
diff --git a/apps/web/src/commands/scene/remove-bookmark.ts b/apps/web/src/commands/scene/remove-bookmark.ts
index ea592556..95403e0e 100644
--- a/apps/web/src/commands/scene/remove-bookmark.ts
+++ b/apps/web/src/commands/scene/remove-bookmark.ts
@@ -6,12 +6,13 @@ import {
getFrameTime,
removeBookmarkFromArray,
} from "@/timeline/bookmarks/index";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
- private frameTime: number = 0;
+ private frameTime: MediaTime = ZERO_MEDIA_TIME;
- constructor(private time: number) {
+ constructor(private time: MediaTime) {
super();
}
diff --git a/apps/web/src/commands/scene/rename-scene.ts b/apps/web/src/commands/scene/rename-scene.ts
index 19d6bfd8..8bc8724b 100644
--- a/apps/web/src/commands/scene/rename-scene.ts
+++ b/apps/web/src/commands/scene/rename-scene.ts
@@ -7,13 +7,21 @@ export class RenameSceneCommand extends Command {
private savedScenes: TScene[] | null = null;
private previousName: string | null = null;
- constructor(
- private sceneId: string,
- private newName: string,
- ) {
+ constructor({
+ sceneId,
+ newName,
+ }: {
+ sceneId: string;
+ newName: string;
+ }) {
super();
+ this.sceneId = sceneId;
+ this.newName = newName;
}
+ private sceneId: string;
+ private newName: string;
+
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes();
diff --git a/apps/web/src/commands/scene/toggle-bookmark.ts b/apps/web/src/commands/scene/toggle-bookmark.ts
index 4193c76e..31751be4 100644
--- a/apps/web/src/commands/scene/toggle-bookmark.ts
+++ b/apps/web/src/commands/scene/toggle-bookmark.ts
@@ -6,12 +6,13 @@ import {
getFrameTime,
toggleBookmarkInArray,
} from "@/timeline/bookmarks/index";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
- private frameTime: number = 0;
+ private frameTime: MediaTime = ZERO_MEDIA_TIME;
- constructor(private time: number) {
+ constructor(private time: MediaTime) {
super();
}
diff --git a/apps/web/src/commands/scene/update-bookmark.ts b/apps/web/src/commands/scene/update-bookmark.ts
index 0d6a0193..7208abc4 100644
--- a/apps/web/src/commands/scene/update-bookmark.ts
+++ b/apps/web/src/commands/scene/update-bookmark.ts
@@ -6,17 +6,26 @@ import {
getFrameTime,
updateBookmarkInArray,
} from "@/timeline/bookmarks/index";
+import type { MediaTime } from "@/wasm";
export class UpdateBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
- constructor(
- private time: number,
- private updates: Partial<Omit<Bookmark, "time">>,
- ) {
+ constructor({
+ time,
+ updates,
+ }: {
+ time: MediaTime;
+ updates: Partial<Omit<Bookmark, "time">>;
+ }) {
super();
+ this.time = time;
+ this.updates = updates;
}
+ private time: MediaTime;
+ private updates: Partial<Omit<Bookmark, "time">>;
+
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene();
diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
index c19bd526..a587ef14 100644
--- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
+++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
@@ -10,6 +10,13 @@ import type { SceneTracks, TimelineElement } from "@/timeline";
import { updateElementInSceneTracks } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import { generateUUID } from "@/utils/id";
+import {
+ addMediaTime,
+ type MediaTime,
+ maxMediaTime,
+ minMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
function pasteKeyframesIntoElement({
element,
@@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({
clipboardItems,
}: {
element: TimelineElement;
- time: number;
+ time: MediaTime;
clipboardItems: KeyframeClipboardItem[];
}): TimelineElement {
let nextElement = element;
@@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({
continue;
}
- const keyframeTime = Math.max(
- 0,
- Math.min(time + item.timeOffset, nextElement.duration),
- );
+ const keyframeTime = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: minMediaTime({
+ a: addMediaTime({ a: time, b: item.timeOffset }),
+ b: nextElement.duration,
+ }),
+ });
const nextAnimations = upsertPathKeyframe({
animations: nextElement.animations,
propertyPath: item.propertyPath,
@@ -42,8 +52,7 @@ function pasteKeyframesIntoElement({
value: item.value,
interpolation: item.interpolation,
keyframeId: generateUUID(),
- kind: target.kind,
- defaultInterpolation: target.defaultInterpolation,
+ channelLayout: target.channelLayout,
coerceValue: target.coerceValue,
});
const pastedKeyframe = getKeyframeAtTime({
@@ -79,7 +88,7 @@ export class PasteKeyframesCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
- private readonly time: number;
+ private readonly time: MediaTime;
private readonly clipboardItems: KeyframeClipboardItem[];
constructor({
@@ -90,7 +99,7 @@ export class PasteKeyframesCommand extends Command {
}: {
trackId: string;
elementId: string;
- time: number;
+ time: MediaTime;
clipboardItems: KeyframeClipboardItem[];
}) {
super();
diff --git a/apps/web/src/commands/timeline/clipboard/paste.ts b/apps/web/src/commands/timeline/clipboard/paste.ts
index 4523566e..be0c2b91 100644
--- a/apps/web/src/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/commands/timeline/clipboard/paste.ts
@@ -13,18 +13,25 @@ import {
enforceMainTrackStart,
} from "@/timeline/placement";
import { cloneAnimations } from "@/animation";
+import {
+ addMediaTime,
+ type MediaTime,
+ maxMediaTime,
+ subMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
export class PasteCommand extends Command {
private savedState: SceneTracks | null = null;
private pastedElements: { trackId: string; elementId: string }[] = [];
- private readonly time: number;
+ private readonly time: MediaTime;
private readonly clipboardItems: ElementClipboardItem[];
constructor({
time,
clipboardItems,
}: {
- time: number;
+ time: MediaTime;
clipboardItems: ElementClipboardItem[];
}) {
super();
@@ -39,8 +46,12 @@ export class PasteCommand extends Command {
this.savedState = editor.scenes.getActiveScene().tracks;
this.pastedElements = [];
- const minStart = Math.min(
- ...this.clipboardItems.map((item) => item.element.startTime),
+ const minStart = this.clipboardItems.reduce(
+ (earliestStartTime, item) =>
+ item.element.startTime < earliestStartTime
+ ? item.element.startTime
+ : earliestStartTime,
+ this.clipboardItems[0].element.startTime,
);
let updatedTracks = this.savedState;
@@ -97,12 +108,18 @@ export class PasteCommand extends Command {
targetTrackId: targetTrack.id,
requestedStartTime: earliestElement.startTime,
});
- const delta = adjustedEarliestStartTime - earliestElement.startTime;
+ const delta = subMediaTime({
+ a: adjustedEarliestStartTime,
+ b: earliestElement.startTime,
+ });
- if (delta !== 0) {
+ if (delta !== ZERO_MEDIA_TIME) {
elementsForPlacement = elementsToAdd.map((element) => ({
...element,
- startTime: Math.max(0, element.startTime + delta),
+ startTime: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: addMediaTime({ a: element.startTime, b: delta }),
+ }),
}));
}
}
@@ -168,14 +185,20 @@ function buildPastedElements({
time,
}: {
items: ElementClipboardItem[];
- minStart: number;
- time: number;
+ minStart: MediaTime;
+ time: MediaTime;
}): TimelineElement[] {
const elementsToAdd: TimelineElement[] = [];
for (const item of items) {
- const relativeOffset = item.element.startTime - minStart;
- const startTime = Math.max(0, time + relativeOffset);
+ const relativeOffset = subMediaTime({
+ a: item.element.startTime,
+ b: minStart,
+ });
+ const startTime = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: addMediaTime({ a: time, b: relativeOffset }),
+ });
const newElementId = generateUUID();
elementsToAdd.push({
diff --git a/apps/web/src/commands/timeline/element/duplicate-elements.ts b/apps/web/src/commands/timeline/element/duplicate-elements.ts
index 8f65f7c7..2b1ab4ea 100644
--- a/apps/web/src/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/commands/timeline/element/duplicate-elements.ts
@@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
import { cloneAnimations } from "@/animation";
+import type { MediaTime } from "@/wasm";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
@@ -119,7 +120,7 @@ function buildDuplicateElement({
}: {
element: TimelineElement;
id: string;
- startTime: number;
+ startTime: MediaTime;
}): TimelineElement {
return {
...element,
diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts
index 49ac6f2d..dfde0ffe 100644
--- a/apps/web/src/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/commands/timeline/element/insert-element.ts
@@ -18,6 +18,7 @@ import {
resolveTrackPlacement,
validateElementTrackCompatibility,
} from "@/timeline/placement";
+import { roundMediaTime } from "@/wasm";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -188,7 +189,7 @@ export class InsertElementCommand extends Command {
}
}
- if (element.type === "text" && !element.content) {
+ if (element.type === "text" && !element.params.content) {
console.error("Text element must have content");
return false;
}
@@ -266,7 +267,12 @@ export class InsertElementCommand extends Command {
placementResult.kind === "existingTrack"
? {
...element,
- startTime: placementResult.adjustedStartTime ?? element.startTime,
+ startTime:
+ placementResult.adjustedStartTime !== undefined
+ ? roundMediaTime({
+ time: placementResult.adjustedStartTime,
+ })
+ : element.startTime,
}
: element;
diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
index 07abe2be..ad12e680 100644
--- a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -5,7 +5,8 @@ import {
} from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
-import type { AnimationPath, AnimationValue } from "@/animation/types";
+import type { AnimationPath } from "@/animation/types";
+import type { ParamValue } from "@/params";
import type { SceneTracks, TimelineElement } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
@@ -18,7 +19,7 @@ function removeKeyframeAndPersist({
element: TimelineElement;
propertyPath: AnimationPath;
keyframeId: string;
- valueAtPlayhead: AnimationValue | null;
+ valueAtPlayhead: ParamValue | null;
}): TimelineElement {
const target = resolveAnimationTarget({ element, path: propertyPath });
if (!target) {
@@ -52,7 +53,7 @@ export class RemoveKeyframeCommand extends Command {
private readonly elementId: string;
private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
- private readonly valueAtPlayhead: AnimationValue | null;
+ private readonly valueAtPlayhead: ParamValue | null;
constructor({
trackId,
@@ -65,7 +66,7 @@ export class RemoveKeyframeCommand extends Command {
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
- valueAtPlayhead: AnimationValue | null;
+ valueAtPlayhead: ParamValue | null;
}) {
super();
this.trackId = trackId;
diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
index aa04e2bc..94054372 100644
--- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -4,6 +4,12 @@ import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
import type { AnimationPath } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
+import {
+ type MediaTime,
+ maxMediaTime,
+ minMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
export class RetimeKeyframeCommand extends Command {
@@ -12,7 +18,7 @@ export class RetimeKeyframeCommand extends Command {
private readonly elementId: string;
private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
- private readonly nextTime: number;
+ private readonly nextTime: MediaTime;
constructor({
trackId,
@@ -25,7 +31,7 @@ export class RetimeKeyframeCommand extends Command {
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
- nextTime: number;
+ nextTime: MediaTime;
}) {
super();
this.trackId = trackId;
@@ -48,7 +54,10 @@ export class RetimeKeyframeCommand extends Command {
return element;
}
- const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
+ const boundedTime = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: minMediaTime({ a: this.nextTime, b: element.duration }),
+ });
return {
...element,
animations: retimeElementKeyframe({
diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
index 7bd1b2e5..babe1ad0 100644
--- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -9,6 +9,12 @@ import { isVisualElement } from "@/timeline/element-utils";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { AnimationInterpolation } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
+import {
+ type MediaTime,
+ maxMediaTime,
+ minMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
@@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
private readonly elementId: string;
private readonly effectId: string;
private readonly paramKey: string;
- private readonly time: number;
+ private readonly time: MediaTime;
private readonly value: number | string | boolean;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
@@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: string;
effectId: string;
paramKey: string;
- time: number;
+ time: MediaTime;
value: number | string | boolean;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
- const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ const boundedTime = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: minMediaTime({ a: this.time, b: element.duration }),
+ });
const propertyPath = buildEffectParamPath({
effectId: this.effectId,
paramKey: this.paramKey,
@@ -81,8 +90,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
- kind: target.kind,
- defaultInterpolation: target.defaultInterpolation,
+ channelLayout: target.channelLayout,
coerceValue: target.coerceValue,
});
return { ...element, animations };
diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
index 15c18bfc..8ba13a11 100644
--- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -7,16 +7,22 @@ import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type {
AnimationPath,
AnimationInterpolation,
- AnimationValue,
} from "@/animation/types";
+import type { ParamValue } from "@/params";
+import {
+ type MediaTime,
+ maxMediaTime,
+ minMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
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 time: MediaTime;
+ private readonly value: ParamValue;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
@@ -32,8 +38,8 @@ export class UpsertKeyframeCommand extends Command {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
- time: number;
- value: AnimationValue;
+ time: MediaTime;
+ value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}) {
@@ -64,7 +70,10 @@ export class UpsertKeyframeCommand extends Command {
return element;
}
- const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ const boundedTime = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: minMediaTime({ a: this.time, b: element.duration }),
+ });
return {
...element,
animations: upsertPathKeyframe({
@@ -74,8 +83,7 @@ export class UpsertKeyframeCommand extends Command {
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
- kind: target.kind,
- defaultInterpolation: target.defaultInterpolation,
+ channelLayout: target.channelLayout,
coerceValue: target.coerceValue,
}),
};
diff --git a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts
index 1445b1f8..fb02554d 100644
--- a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts
+++ b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts
@@ -1,22 +1,22 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
import {
- getCustomMaskClosedStateAfterPointRemoval,
- removeCustomMaskPoints,
-} from "@/masks/custom-path";
-import type { CustomMask } from "@/masks/types";
+ getFreeformPathClosedStateAfterPointRemoval,
+ removeFreeformPathPoints,
+} from "@/masks/freeform/path";
+import type { FreeformPathMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline";
-function deletePointsFromCustomMask({
+function deletePointsFromFreeformPathMask({
mask,
pointIds,
}: {
- mask: CustomMask;
+ mask: FreeformPathMask;
pointIds: string[];
-}): CustomMask {
+}): FreeformPathMask {
const points = mask.params.path;
- const nextPoints = removeCustomMaskPoints({ points, pointIds });
+ const nextPoints = removeFreeformPathPoints({ points, pointIds });
if (nextPoints.length === points.length) {
return mask;
}
@@ -26,7 +26,7 @@ function deletePointsFromCustomMask({
params: {
...mask.params,
path: nextPoints,
- closed: getCustomMaskClosedStateAfterPointRemoval({
+ closed: getFreeformPathClosedStateAfterPointRemoval({
wasClosed: mask.params.closed,
remainingPointCount: nextPoints.length,
}),
@@ -46,11 +46,11 @@ function deletePointsFromElementMask({
const currentMasks = element.masks ?? [];
let didDeletePoints = false;
const nextMasks = currentMasks.map((mask) => {
- if (mask.id !== maskId || mask.type !== "custom") {
+ if (mask.id !== maskId || mask.type !== "freeform") {
return mask;
}
- const nextMask = deletePointsFromCustomMask({
+ const nextMask = deletePointsFromFreeformPathMask({
mask,
pointIds,
});
@@ -64,7 +64,7 @@ function deletePointsFromElementMask({
};
}
-export class DeleteCustomMaskPointsCommand extends Command {
+export class DeleteFreeformPathMaskPointsCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
@@ -100,8 +100,9 @@ export class DeleteCustomMaskPointsCommand extends Command {
elementId: this.elementId,
elementPredicate: isMaskableElement,
update: (element) => {
+ if (!isMaskableElement(element)) return element;
const result = deletePointsFromElementMask({
- element: element as MaskableElement,
+ element,
maskId: this.maskId,
pointIds: this.pointIds,
});
diff --git a/apps/web/src/commands/timeline/element/masks/index.ts b/apps/web/src/commands/timeline/element/masks/index.ts
index dd6373b1..70b4333e 100644
--- a/apps/web/src/commands/timeline/element/masks/index.ts
+++ b/apps/web/src/commands/timeline/element/masks/index.ts
@@ -1,4 +1,4 @@
-export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
-export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
+export { DeleteFreeformPathMaskPointsCommand } from "./delete-custom-mask-points";
+export { InsertFreeformPathMaskPointCommand } from "./insert-custom-mask-point";
export { RemoveMaskCommand } from "./remove-mask";
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
diff --git a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts
index 58559af6..0f714884 100644
--- a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts
+++ b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts
@@ -1,23 +1,23 @@
import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command";
-import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom";
+import { insertPointOnFreeformSegment } from "@/masks/freeform/definition";
import type { ElementBounds } from "@/preview/element-bounds";
-import type { CustomMask } from "@/masks/types";
+import type { FreeformPathMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline";
-function insertPointIntoCustomMask({
+function insertPointIntoFreeformPathMask({
mask,
segmentIndex,
canvasPoint,
bounds,
}: {
- mask: CustomMask;
+ mask: FreeformPathMask;
segmentIndex: number;
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
-}): { mask: CustomMask; insertedPointId: string | null } {
- const result = insertPointOnCustomMaskSegment({
+}): { mask: FreeformPathMask; insertedPointId: string | null } {
+ const result = insertPointOnFreeformSegment({
params: mask.params,
segmentIndex,
canvasPoint,
@@ -61,11 +61,11 @@ function insertPointIntoElementMask({
let didInsertPoint = false;
const nextMasks = currentMasks.map((mask) => {
- if (mask.id !== maskId || mask.type !== "custom") {
+ if (mask.id !== maskId || mask.type !== "freeform") {
return mask;
}
- const result = insertPointIntoCustomMask({
+ const result = insertPointIntoFreeformPathMask({
mask,
segmentIndex,
canvasPoint,
@@ -85,7 +85,7 @@ function insertPointIntoElementMask({
};
}
-export class InsertCustomMaskPointCommand extends Command {
+export class InsertFreeformPathMaskPointCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
@@ -130,8 +130,9 @@ export class InsertCustomMaskPointCommand extends Command {
elementId: this.elementId,
elementPredicate: isMaskableElement,
update: (element) => {
+ if (!isMaskableElement(element)) return element;
const result = insertPointIntoElementMask({
- element: element as MaskableElement,
+ element,
maskId: this.maskId,
segmentIndex: this.segmentIndex,
canvasPoint: this.canvasPoint,
diff --git a/apps/web/src/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/commands/timeline/element/masks/remove-mask.ts
index b543bce1..b92d1d8c 100644
--- a/apps/web/src/commands/timeline/element/masks/remove-mask.ts
+++ b/apps/web/src/commands/timeline/element/masks/remove-mask.ts
@@ -45,11 +45,13 @@ export class RemoveMaskCommand extends Command {
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isMaskableElement,
- update: (element) =>
- removeMaskFromElement({
- element: element as MaskableElement,
+ update: (element) => {
+ if (!isMaskableElement(element)) return element;
+ return removeMaskFromElement({
+ element,
maskId: this.maskId,
- }),
+ });
+ },
});
editor.timeline.updateTracks(updatedTracks);
diff --git a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts
index fec9b923..b53e24ca 100644
--- a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts
+++ b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts
@@ -56,11 +56,13 @@ export class ToggleMaskInvertedCommand extends Command {
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isMaskableElement,
- update: (element) =>
- toggleMaskInvertedOnElement({
- element: element as MaskableElement,
+ update: (element) => {
+ if (!isMaskableElement(element)) return element;
+ return toggleMaskInvertedOnElement({
+ element,
maskId: this.maskId,
- }),
+ });
+ },
});
editor.timeline.updateTracks(updatedTracks);
diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts
index 11c317f4..0a551fc3 100644
--- a/apps/web/src/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/commands/timeline/element/split-elements.ts
@@ -9,12 +9,18 @@ import { EditorCore } from "@/core";
import { isRetimableElement } from "@/timeline";
import { splitAnimationsAtTime } from "@/animation";
import { getSourceSpanAtClipTime } from "@/retime";
+import {
+ addMediaTime,
+ type MediaTime,
+ roundMediaTime,
+ subMediaTime,
+} from "@/wasm";
export class SplitElementsCommand extends Command {
private savedState: SceneTracks | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[];
- private readonly splitTime: number;
+ private readonly splitTime: MediaTime;
private readonly retainSide: "both" | "left" | "right";
constructor({
@@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command {
retainSide = "both",
}: {
elements: { trackId: string; elementId: string }[];
- splitTime: number;
+ splitTime: MediaTime;
retainSide?: "both" | "left" | "right";
}) {
super();
@@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command {
return [element];
}
- const relativeTime = this.splitTime - element.startTime;
+ const relativeTime = subMediaTime({
+ a: this.splitTime,
+ b: element.startTime,
+ });
const leftVisibleDuration = relativeTime;
- const rightVisibleDuration = element.duration - relativeTime;
+ const rightVisibleDuration = subMediaTime({
+ a: element.duration,
+ b: relativeTime,
+ });
const retimeRef = isRetimableElement(element)
? element.retime
: undefined;
- const leftSourceSpan = getSourceSpanAtClipTime({
- clipTime: leftVisibleDuration,
- retime: retimeRef,
+ // Snap the source-side split point exactly once and derive the right
+ // half from it. Independently rounding both spans (left and total)
+ // would let a 1-tick rounding error desynchronise them, breaking the
+ // invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`.
+ // See the same discipline in `compute-resize.ts` (snap-once comment).
+ const leftSourceSpan = roundMediaTime({
+ time: getSourceSpanAtClipTime({
+ clipTime: leftVisibleDuration,
+ retime: retimeRef,
+ }),
+ });
+ const totalSourceSpan = roundMediaTime({
+ time: getSourceSpanAtClipTime({
+ clipTime: element.duration,
+ retime: retimeRef,
+ }),
});
- const totalSourceSpan = getSourceSpanAtClipTime({
- clipTime: element.duration,
- retime: retimeRef,
+ const rightSourceSpan = subMediaTime({
+ a: totalSourceSpan,
+ b: leftSourceSpan,
});
- const rightSourceSpan = totalSourceSpan - leftSourceSpan;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations,
splitTime: relativeTime,
@@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command {
});
let splitResult: TimelineElement[];
+ const leftTrimEnd = addMediaTime({
+ a: element.trimEnd,
+ b: rightSourceSpan,
+ });
+ const rightTrimStart = addMediaTime({
+ a: element.trimStart,
+ b: leftSourceSpan,
+ });
+
if (this.retainSide === "left") {
splitResult = [
{
...element,
duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightSourceSpan,
+ trimEnd: leftTrimEnd,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command {
id: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
- trimStart: element.trimStart + leftSourceSpan,
+ trimStart: rightTrimStart,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
];
} else {
- // "both" - split into two pieces
const secondElementId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
@@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command {
{
...element,
duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightSourceSpan,
+ trimEnd: leftTrimEnd,
name: `${element.name} (left)`,
animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command {
id: secondElementId,
startTime: this.splitTime,
duration: rightVisibleDuration,
- trimStart: element.trimStart + leftSourceSpan,
+ trimStart: rightTrimStart,
name: `${element.name} (right)`,
animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
diff --git a/apps/web/src/commands/timeline/track/add-track.ts b/apps/web/src/commands/timeline/track/add-track.ts
index 6b58b687..90becf13 100644
--- a/apps/web/src/commands/timeline/track/add-track.ts
+++ b/apps/web/src/commands/timeline/track/add-track.ts
@@ -11,14 +11,22 @@ export class AddTrackCommand extends Command {
private trackId: string;
private savedState: SceneTracks | null = null;
- constructor(
- private type: TrackType,
- private index?: number,
- ) {
+ constructor({
+ type,
+ index,
+ }: {
+ type: TrackType;
+ index?: number;
+ }) {
super();
+ this.type = type;
+ this.index = index;
this.trackId = generateUUID();
}
+ private type: TrackType;
+ private index?: number;
+
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks;
diff --git a/apps/web/src/commands/timeline/tracks-snapshot.ts b/apps/web/src/commands/timeline/tracks-snapshot.ts
index 5b6d871a..8e55e602 100644
--- a/apps/web/src/commands/timeline/tracks-snapshot.ts
+++ b/apps/web/src/commands/timeline/tracks-snapshot.ts
@@ -3,13 +3,21 @@ import type { SceneTracks } from "@/timeline";
import { EditorCore } from "@/core";
export class TracksSnapshotCommand extends Command {
- constructor(
- private before: SceneTracks,
- private after: SceneTracks,
- ) {
+ constructor({
+ before,
+ after,
+ }: {
+ before: SceneTracks;
+ after: SceneTracks;
+ }) {
super();
+ this.before = before;
+ this.after = after;
}
+ private before: SceneTracks;
+ private after: SceneTracks;
+
execute(): CommandResult | undefined {
EditorCore.getInstance().timeline.updateTracks(this.after);
return undefined;
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx
index 276cb490..aaae79a9 100644
--- a/apps/web/src/components/editable-timecode.tsx
+++ b/apps/web/src/components/editable-timecode.tsx
@@ -1,15 +1,24 @@
"use client";
import { useEffect, useRef, useState } from "react";
-import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
+import {
+ formatTimecode,
+ type FrameRate,
+ type TimeCodeFormat,
+} from "opencut-wasm";
import { cn } from "@/utils/ui";
+import {
+ parseMediaTimecode,
+ snapSeekMediaTime,
+ type MediaTime,
+} from "@/wasm";
interface EditableTimecodeProps {
- time: number;
- duration: number;
+ time: MediaTime;
+ duration: MediaTime;
format?: TimeCodeFormat;
fps: FrameRate;
- onTimeChange?: ({ time }: { time: number }) => void;
+ onTimeChange?: ({ time }: { time: MediaTime }) => void;
className?: string;
disabled?: boolean;
}
@@ -46,7 +55,11 @@ export function EditableTimecode({
};
const applyEdit = () => {
- const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
+ const parsedTime = parseMediaTimecode({
+ timeCode: inputValue,
+ format,
+ fps,
+ });
if (parsedTime == null) {
setHasError(true);
@@ -54,7 +67,7 @@ export function EditableTimecode({
}
const clampedTime = duration
- ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
+ ? snapSeekMediaTime({ time: parsedTime, duration, fps })
: parsedTime;
onTimeChange?.({ time: clampedTime });
diff --git a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
index 1539d305..b412de7b 100644
--- a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
+++ b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx
@@ -93,7 +93,7 @@ interface AssetsPanelStore {
setMediaViewMode: (mode: MediaViewMode) => void;
mediaSortBy: MediaSortKey;
mediaSortOrder: MediaSortOrder;
- setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void;
+ setMediaSort: (args: { key: MediaSortKey; order: MediaSortOrder }) => void;
}
export const useAssetsPanelStore = create<AssetsPanelStore>()(
@@ -109,7 +109,7 @@ export const useAssetsPanelStore = create<AssetsPanelStore>()(
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
mediaSortBy: "name",
mediaSortOrder: "asc",
- setMediaSort: (key, order) =>
+ setMediaSort: ({ key, order }) =>
set({ mediaSortBy: key, mediaSortOrder: order }),
}),
{
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
index 7cf89fbe..8cfb99c3 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -1,240 +1,241 @@
-"use client";
-
-import { Plus } from "lucide-react";
-import { type ReactNode, useEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { AspectRatio } from "@/components/ui/aspect-ratio";
-import { Button } from "@/components/ui/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { useEditor } from "@/editor/use-editor";
-import type { TimelineDragData } from "@/timeline/drag";
-import { cn } from "@/utils/ui";
-
-export interface DraggableItemProps {
- name: string;
- preview: ReactNode;
- dragData: TimelineDragData;
- onDragStart?: ({ e }: { e: React.DragEvent }) => void;
- onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
- aspectRatio?: number;
- className?: string;
- containerClassName?: string;
- shouldShowPlusOnDrag?: boolean;
- shouldShowLabel?: boolean;
- isRounded?: boolean;
- variant?: "card" | "compact";
- isDraggable?: boolean;
-}
-
-export function DraggableItem({
- name,
- preview,
- dragData,
- onDragStart,
- onAddToTimeline,
- aspectRatio = 16 / 9,
- className = "",
- containerClassName,
- shouldShowPlusOnDrag = true,
- shouldShowLabel = true,
- isRounded = true,
- variant = "card",
- isDraggable = true,
-}: DraggableItemProps) {
- const [isDragging, setIsDragging] = useState(false);
- const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
- const dragRef = useRef<HTMLDivElement>(null);
- const editor = useEditor();
-
- const handleAddToTimeline = () => {
- onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
- };
-
- const emptyImg = new window.Image();
- emptyImg.src =
- "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
-
- useEffect(() => {
- if (!isDragging) return;
-
- const handleDragOver = (e: DragEvent) => {
- setDragPosition({ x: e.clientX, y: e.clientY });
- };
-
- document.addEventListener("dragover", handleDragOver);
-
- return () => {
- document.removeEventListener("dragover", handleDragOver);
- };
- }, [isDragging]);
-
- const handleDragStart = (event: React.DragEvent) => {
- event.dataTransfer.setDragImage(emptyImg, 0, 0);
-
- editor.timeline.dragSource.begin({
- dataTransfer: event.dataTransfer,
- dragData,
- });
-
- setDragPosition({ x: event.clientX, y: event.clientY });
- setIsDragging(true);
-
- onDragStart?.({ e: event });
- };
-
- const handleDragEnd = () => {
- setIsDragging(false);
- editor.timeline.dragSource.end();
- };
-
- return (
- <>
- {variant === "card" ? (
- <div
- ref={dragRef}
- className={cn("group relative", containerClassName ?? "w-28")}
- >
- <div
- className={cn(
- "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
- className,
- )}
- >
- <AspectRatio
- ratio={aspectRatio}
- className={cn(
- "bg-accent relative overflow-hidden",
- isRounded && "rounded-sm",
- isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
- )}
- draggable={isDraggable}
- onDragStart={isDraggable ? handleDragStart : undefined}
- onDragEnd={isDraggable ? handleDragEnd : undefined}
- >
- {preview}
- {!isDragging && (
- <PlusButton
- className="opacity-0 group-hover:opacity-100"
- onClick={handleAddToTimeline}
- />
- )}
- </AspectRatio>
- {shouldShowLabel && (
- <span
- className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
- title={name}
- >
- <span className="sr-only">{name}</span>
- <span aria-hidden="true">
- {name.length > 8
- ? `${name.slice(0, 16)}...${name.slice(-3)}`
- : name}
- </span>
- </span>
- )}
- </div>
- </div>
- ) : (
- <div
- ref={dragRef}
- className={cn("group relative w-full", containerClassName)}
- >
- <button
- type="button"
- className={cn(
- "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
- isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
- className,
- )}
- draggable={isDraggable}
- onDragStart={isDraggable ? handleDragStart : undefined}
- onDragEnd={isDraggable ? handleDragEnd : undefined}
- >
- <div className="size-6 shrink-0 overflow-hidden rounded-sm">
- {preview}
- </div>
- <span className="w-full flex-1 truncate text-sm text-left">
- {name}
- </span>
- </button>
- </div>
- )}
-
- {isDraggable &&
- isDragging &&
- typeof document !== "undefined" &&
- createPortal(
- <div
- className="pointer-events-none fixed z-9999"
- style={{
- left: dragPosition.x - 40,
- top: dragPosition.y - 40,
- }}
- >
- <div className="w-[80px]">
- <AspectRatio
- ratio={1}
- className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
- >
- <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
- {preview}
- </div>
- {shouldShowPlusOnDrag && (
- <PlusButton
- onClick={handleAddToTimeline}
- tooltipText="Add to timeline or drag to position"
- />
- )}
- </AspectRatio>
- </div>
- </div>,
- document.body,
- )}
- </>
- );
-}
-
-function PlusButton({
- className,
- onClick,
- tooltipText,
-}: {
- className?: string;
- onClick?: () => void;
- tooltipText?: string;
-}) {
- const button = (
- <Button
- size="icon"
- className={cn(
- "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
- className,
- )}
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- onClick?.();
- }}
- title={tooltipText}
- >
- <Plus />
- </Button>
- );
-
- if (tooltipText) {
- return (
- <Tooltip>
- <TooltipTrigger asChild>{button}</TooltipTrigger>
- <TooltipContent>
- <p>{tooltipText}</p>
- </TooltipContent>
- </Tooltip>
- );
- }
-
- return button;
-}
+"use client";
+
+import { Plus } from "lucide-react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/editor/use-editor";
+import type { TimelineDragData } from "@/timeline/drag";
+import { cn } from "@/utils/ui";
+import type { MediaTime } from "@/wasm";
+
+export interface DraggableItemProps {
+ name: string;
+ preview: ReactNode;
+ dragData: TimelineDragData;
+ onDragStart?: ({ e }: { e: React.DragEvent }) => void;
+ onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
+ aspectRatio?: number;
+ className?: string;
+ containerClassName?: string;
+ shouldShowPlusOnDrag?: boolean;
+ shouldShowLabel?: boolean;
+ isRounded?: boolean;
+ variant?: "card" | "compact";
+ isDraggable?: boolean;
+}
+
+export function DraggableItem({
+ name,
+ preview,
+ dragData,
+ onDragStart,
+ onAddToTimeline,
+ aspectRatio = 16 / 9,
+ className = "",
+ containerClassName,
+ shouldShowPlusOnDrag = true,
+ shouldShowLabel = true,
+ isRounded = true,
+ variant = "card",
+ isDraggable = true,
+}: DraggableItemProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
+ const dragRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
+ };
+
+ const emptyImg = new window.Image();
+ emptyImg.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleDragOver = (e: DragEvent) => {
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ };
+
+ document.addEventListener("dragover", handleDragOver);
+
+ return () => {
+ document.removeEventListener("dragover", handleDragOver);
+ };
+ }, [isDragging]);
+
+ const handleDragStart = (event: React.DragEvent) => {
+ event.dataTransfer.setDragImage(emptyImg, 0, 0);
+
+ editor.timeline.dragSource.begin({
+ dataTransfer: event.dataTransfer,
+ dragData,
+ });
+
+ setDragPosition({ x: event.clientX, y: event.clientY });
+ setIsDragging(true);
+
+ onDragStart?.({ e: event });
+ };
+
+ const handleDragEnd = () => {
+ setIsDragging(false);
+ editor.timeline.dragSource.end();
+ };
+
+ return (
+ <>
+ {variant === "card" ? (
+ <div
+ ref={dragRef}
+ className={cn("group relative", containerClassName ?? "w-28")}
+ >
+ <div
+ className={cn(
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
+ className,
+ )}
+ >
+ <AspectRatio
+ ratio={aspectRatio}
+ className={cn(
+ "bg-accent relative overflow-hidden",
+ isRounded && "rounded-sm",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ {preview}
+ {!isDragging && (
+ <PlusButton
+ className="opacity-0 group-hover:opacity-100"
+ onClick={handleAddToTimeline}
+ />
+ )}
+ </AspectRatio>
+ {shouldShowLabel && (
+ <span
+ className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
+ title={name}
+ >
+ <span className="sr-only">{name}</span>
+ <span aria-hidden="true">
+ {name.length > 8
+ ? `${name.slice(0, 16)}...${name.slice(-3)}`
+ : name}
+ </span>
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div
+ ref={dragRef}
+ className={cn("group relative w-full", containerClassName)}
+ >
+ <button
+ type="button"
+ className={cn(
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ className,
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
+ {preview}
+ </div>
+ <span className="w-full flex-1 truncate text-sm text-left">
+ {name}
+ </span>
+ </button>
+ </div>
+ )}
+
+ {isDraggable &&
+ isDragging &&
+ typeof document !== "undefined" &&
+ createPortal(
+ <div
+ className="pointer-events-none fixed z-9999"
+ style={{
+ left: dragPosition.x - 40,
+ top: dragPosition.y - 40,
+ }}
+ >
+ <div className="w-[80px]">
+ <AspectRatio
+ ratio={1}
+ className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
+ >
+ <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
+ {preview}
+ </div>
+ {shouldShowPlusOnDrag && (
+ <PlusButton
+ onClick={handleAddToTimeline}
+ tooltipText="Add to timeline or drag to position"
+ />
+ )}
+ </AspectRatio>
+ </div>
+ </div>,
+ document.body,
+ )}
+ </>
+ );
+}
+
+function PlusButton({
+ className,
+ onClick,
+ tooltipText,
+}: {
+ className?: string;
+ onClick?: () => void;
+ tooltipText?: string;
+}) {
+ const button = (
+ <Button
+ size="icon"
+ className={cn(
+ "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
+ className,
+ )}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
+ title={tooltipText}
+ >
+ <Plus />
+ </Button>
+ );
+
+ if (tooltipText) {
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ );
+ }
+
+ return button;
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 4afd22b7..00cb015f 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -26,7 +26,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
-import { TICKS_PER_SECOND } from "@/wasm";
+import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
import { useEditor } from "@/editor/use-editor";
import { useFileUpload } from "@/media/use-file-upload";
import { invokeAction } from "@/actions";
@@ -139,9 +139,12 @@ export function MediaView() {
const handleSort = ({ key }: { key: MediaSortKey }) => {
if (mediaSortBy === key) {
- setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc");
+ setMediaSort({
+ key,
+ order: mediaSortOrder === "asc" ? "desc" : "asc",
+ });
} else {
- setMediaSort(key, "asc");
+ setMediaSort({ key, order: "asc" });
}
};
@@ -255,11 +258,11 @@ function MediaAssetDraggable({
startTime,
}: {
asset: MediaAsset;
- startTime: number;
+ startTime: MediaTime;
}) => {
const duration =
asset.duration != null
- ? Math.round(asset.duration * TICKS_PER_SECOND)
+ ? mediaTimeFromSeconds({ seconds: asset.duration })
: DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({
mediaId: asset.id,
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
index bf3a1c16..5edbeab3 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useRef, useState } from "react";
+import { useState } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import {
Select,
@@ -34,6 +34,10 @@ import type { TCanvasSize } from "@/project/types";
type SettingsView = "project-info" | "background";
+function isSettingsView(value: string): value is SettingsView {
+ return value === "project-info" || value === "background";
+}
+
const PRESET_LABELS: Record<string, string> = {
"1:1": "1:1",
"16:9": "16:9",
@@ -73,23 +77,20 @@ function useCanvasDimensionDraft({
value: number;
onCommit: (value: number) => void;
}) {
- const pendingValueRef = useRef(value);
- const syncedValueRef = useRef(value);
-
- if (syncedValueRef.current !== value) {
- syncedValueRef.current = value;
- pendingValueRef.current = value;
- }
+ const [pendingValue, setPendingValue] = useState(value);
return usePropertyDraft({
displayValue: formatCanvasDimension({ value }),
parse: (input) => parseCanvasDimension({ input }),
+ onStartEditing: () => {
+ setPendingValue(value);
+ },
onPreview: (nextValue) => {
- pendingValueRef.current = nextValue;
+ setPendingValue(nextValue);
},
onCommit: () => {
- if (pendingValueRef.current !== value) {
- onCommit(pendingValueRef.current);
+ if (pendingValue !== value) {
+ onCommit(pendingValue);
}
},
});
@@ -212,7 +213,14 @@ export function SettingsView() {
contentClassName="px-0"
scrollClassName="pt-0"
actions={
- <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
+ <Tabs
+ value={view}
+ onValueChange={(value) => {
+ if (isSettingsView(value)) {
+ setView(value);
+ }
+ }}
+ >
<TabsList>
<TabsTrigger value="project-info">Project info</TabsTrigger>
<TabsTrigger value="background">Background</TabsTrigger>
diff --git a/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx
new file mode 100644
index 00000000..2befcef4
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { resolveAnimationPathValueAtTime } from "@/animation";
+import { Section, SectionContent, SectionFields } from "@/components/section";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import { useKeyframedParamProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
+import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
+import type { ParamValue, ParamValues } from "@/params";
+import {
+ getElementParams,
+ readElementParamValue,
+ writeElementParamValue,
+ type ElementParamDefinition,
+} from "@/params/registry";
+import type { TimelineElement } from "@/timeline";
+import type { MediaTime } from "@/wasm";
+
+export function ElementParamsTab({
+ element,
+ trackId,
+ paramKeys,
+ sectionKey,
+}: {
+ element: TimelineElement;
+ trackId: string;
+ paramKeys?: readonly string[];
+ sectionKey: string;
+}) {
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const params = getElementParams({ element }).filter(
+ (param) => !paramKeys || paramKeys.includes(param.key),
+ );
+ const baseValues = buildValues({ element, params });
+
+ return (
+ <Section sectionKey={`${element.id}:${sectionKey}`}>
+ <SectionContent className="pt-4">
+ <SectionFields>
+ {params
+ .filter((param) => isVisible({ param, values: baseValues }))
+ .map((param) => (
+ <ElementParamField
+ key={param.key}
+ element={element}
+ trackId={trackId}
+ param={param}
+ baseValue={baseValues[param.key] ?? param.default}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function ElementParamField({
+ element,
+ trackId,
+ param,
+ baseValue,
+ localTime,
+ isPlayheadWithinElementRange,
+}: {
+ element: TimelineElement;
+ trackId: string;
+ param: ElementParamDefinition;
+ baseValue: ParamValue;
+ localTime: MediaTime;
+ isPlayheadWithinElementRange: boolean;
+}) {
+ const resolvedValue = resolveAnimationPathValueAtTime({
+ animations: element.animations,
+ propertyPath: param.key,
+ localTime,
+ fallbackValue: baseValue,
+ });
+ const animatedParam = useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: param.key,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ buildBaseUpdates: ({ value }) =>
+ writeElementParamValue({ element, param, value }),
+ });
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedValue}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={
+ param.keyframable === false
+ ? undefined
+ : {
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }
+ }
+ />
+ );
+}
+
+function buildValues({
+ element,
+ params,
+}: {
+ element: TimelineElement;
+ params: readonly ElementParamDefinition[];
+}): ParamValues {
+ const values: ParamValues = {};
+ for (const param of params) {
+ const value = readElementParamValue({ element, param });
+ if (value !== null) {
+ values[param.key] = value;
+ }
+ }
+ return values;
+}
+
+function isVisible({
+ param,
+ values,
+}: {
+ param: ElementParamDefinition;
+ values: ParamValues;
+}): boolean {
+ return (param.dependencies ?? []).every((dependency) =>
+ areParamValuesEqual({
+ left: values[dependency.param],
+ right: dependency.equals,
+ }),
+ );
+}
+
+function areParamValuesEqual({
+ left,
+ right,
+}: {
+ left: ParamValue | undefined;
+ right: ParamValue;
+}): boolean {
+ return left === right;
+}
diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
index c9a9c22e..66225903 100644
--- a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
+++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx
@@ -1,6 +1,10 @@
"use client";
-import type { ParamDefinition, NumberParamDefinition } from "@/params";
+import type {
+ ParamDefinition,
+ NumberParamDefinition,
+ ParamValue,
+} from "@/params";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
@@ -19,6 +23,7 @@ import {
} from "@/components/ui/select";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { KeyframeToggle } from "./keyframe-toggle";
+import { Textarea } from "@/components/ui/textarea";
export function PropertyParamField({
param,
@@ -28,8 +33,8 @@ export function PropertyParamField({
keyframe,
}: {
param: ParamDefinition;
- value: number | string | boolean;
- onPreview: (value: number | string | boolean) => void;
+ value: ParamValue;
+ onPreview: (value: ParamValue) => void;
onCommit: () => void;
keyframe?: {
isActive: boolean;
@@ -41,7 +46,7 @@ export function PropertyParamField({
<SectionField
label={param.label}
beforeLabel={
- keyframe ? (
+ keyframe && param.keyframable !== false ? (
<KeyframeToggle
isActive={keyframe.isActive}
isDisabled={keyframe.isDisabled}
@@ -68,8 +73,8 @@ function ParamInput({
onCommit,
}: {
param: ParamDefinition;
- value: number | string | boolean;
- onPreview: (value: number | string | boolean) => void;
+ value: ParamValue;
+ onPreview: (value: ParamValue) => void;
onCommit: () => void;
}) {
if (param.type === "number") {
@@ -131,6 +136,27 @@ function ParamInput({
);
}
+ if (param.type === "text") {
+ return (
+ <Textarea
+ value={String(value)}
+ onChange={(event) => onPreview(event.currentTarget.value)}
+ onBlur={onCommit}
+ />
+ );
+ }
+
+ if (param.type === "font") {
+ return (
+ <input
+ className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none"
+ value={String(value)}
+ onChange={(event) => onPreview(event.currentTarget.value)}
+ onBlur={onCommit}
+ />
+ );
+ }
+
return null;
}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
index 967156b3..83e31aec 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
@@ -1,22 +1,25 @@
import { useEditor } from "@/editor/use-editor";
import { getElementLocalTime } from "@/animation";
+import { addMediaTime, mediaTime, type MediaTime } from "@/wasm";
export function useElementPlayhead({
startTime,
duration,
}: {
- startTime: number;
- duration: number;
+ startTime: MediaTime;
+ duration: MediaTime;
}) {
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
- const localTime = getElementLocalTime({
- timelineTime: playheadTime,
- elementStartTime: startTime,
- elementDuration: duration,
+ const localTime = mediaTime({
+ ticks: getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: startTime,
+ elementDuration: duration,
+ }),
});
const isPlayheadWithinElementRange =
playheadTime >= startTime &&
- playheadTime <= startTime + duration;
+ playheadTime <= addMediaTime({ a: startTime, b: duration });
return { localTime, isPlayheadWithinElementRange };
}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
deleted file mode 100644
index 80fc171b..00000000
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import { upsertElementKeyframe } from "@/timeline/animation-properties";
-
-export function useKeyframedColorProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor,
- buildBaseUpdates,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- resolvedColor: string;
- buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
-}) {
- const editor = useEditor();
-
- const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const onChange = ({ color }: { color: string }) => {
- if (shouldUseAnimatedChannel) {
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- animations: upsertElementKeyframe({
- animations,
- propertyPath,
- time: localTime,
- value: color,
- }),
- },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
- });
- };
-
- const onChangeEnd = () => editor.timeline.commitPreview();
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
- ],
- });
- };
-
- return {
- isKeyframedAtTime,
- hasAnimatedKeyframes,
- keyframeIdAtTime,
- onChange,
- onChangeEnd,
- toggleKeyframe,
- };
-}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
deleted file mode 100644
index 32222b7a..00000000
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import { upsertElementKeyframe } from "@/timeline/animation-properties";
-import { snapToStep } from "@/utils/math";
-import { usePropertyDraft } from "./use-property-draft";
-
-export function useKeyframedNumberProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- displayValue,
- parse,
- valueAtPlayhead,
- step,
- buildBaseUpdates,
- buildAdditionalKeyframes,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: number;
- isPlayheadWithinElementRange: boolean;
- displayValue: string;
- parse: (input: string) => number | null;
- valueAtPlayhead: number;
- step?: number;
- buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
- buildAdditionalKeyframes?: ({
- value,
- }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
-}) {
- const editor = useEditor();
- const snapValue = (value: number) =>
- step != null ? snapToStep({ value, step }) : value;
-
- const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
- const keyframeAtTime = isPlayheadWithinElementRange
- ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
- : null;
- const keyframeIdAtTime = keyframeAtTime?.id ?? null;
- const isKeyframedAtTime = keyframeAtTime !== null;
- const shouldUseAnimatedChannel =
- hasAnimatedKeyframes && isPlayheadWithinElementRange;
-
- const previewValue = ({ value }: { value: number }) => {
- const nextValue = snapValue(value);
- if (shouldUseAnimatedChannel) {
- const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
- const updatedAnimations = [
- { propertyPath, value: nextValue },
- ...additionalKeyframes,
- ].reduce(
- (currentAnimations, keyframe) =>
- upsertElementKeyframe({
- animations: currentAnimations,
- propertyPath: keyframe.propertyPath,
- time: localTime,
- value: keyframe.value,
- }),
- animations,
- );
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: { animations: updatedAnimations },
- },
- ],
- });
- return;
- }
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: buildBaseUpdates({ value: nextValue }),
- },
- ],
- });
- };
-
- const propertyDraft = usePropertyDraft({
- displayValue,
- parse: (input) => {
- const parsedValue = parse(input);
- return parsedValue === null ? null : snapValue(parsedValue);
- },
- onPreview: (value) => previewValue({ value }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- const toggleKeyframe = () => {
- if (!isPlayheadWithinElementRange) {
- return;
- }
-
- if (keyframeIdAtTime) {
- editor.timeline.removeKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- keyframeId: keyframeIdAtTime,
- },
- ],
- });
- return;
- }
-
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId,
- propertyPath,
- time: localTime,
- value: snapValue(valueAtPlayhead),
- },
- ],
- });
- };
-
- const commitValue = ({ value }: { value: number }) => {
- const nextValue = snapValue(value);
- if (shouldUseAnimatedChannel) {
- const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
- editor.timeline.upsertKeyframes({
- keyframes: [
- { trackId, elementId, propertyPath, time: localTime, value: nextValue },
- ...additionalKeyframes.map((keyframe) => ({
- trackId,
- elementId,
- propertyPath: keyframe.propertyPath,
- time: localTime,
- value: keyframe.value,
- })),
- ],
- });
- return;
- }
-
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId,
- patch: buildBaseUpdates({ value: nextValue }),
- },
- ],
- });
- };
-
- return {
- ...propertyDraft,
- hasAnimatedKeyframes,
- isKeyframedAtTime,
- keyframeIdAtTime,
- toggleKeyframe,
- commitValue,
- };
-}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
index 18e027c7..0a0f7db0 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
@@ -8,15 +8,16 @@ import {
upsertPathKeyframe,
} from "@/animation";
import type {
+ AnimationPath,
ElementAnimations,
} from "@/animation/types";
import {
- coerceAnimationParamValue,
- getAnimationParamDefaultInterpolation,
- getAnimationParamValueKind,
-} from "@/animation/animated-params";
-import type { ParamDefinition } from "@/params";
+ coerceParamValue,
+ getParamChannelLayout,
+ type ParamDefinition,
+} from "@/params";
import type { TimelineElement } from "@/timeline";
+import type { MediaTime } from "@/wasm";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean;
@@ -32,6 +33,7 @@ export function useKeyframedParamProperty({
trackId,
elementId,
animations,
+ propertyPath,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
@@ -41,7 +43,8 @@ export function useKeyframedParamProperty({
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
- localTime: number;
+ propertyPath?: AnimationPath;
+ localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean;
buildBaseUpdates: ({
@@ -51,15 +54,16 @@ export function useKeyframedParamProperty({
}) => Partial<TimelineElement>;
}): KeyframedParamPropertyResult {
const editor = useEditor();
- const propertyPath = buildGraphicParamPath({ paramKey: param.key });
+ const resolvedPropertyPath =
+ propertyPath ?? buildGraphicParamPath({ paramKey: param.key });
const hasAnimatedKeyframes = hasKeyframesForPath({
animations,
- propertyPath,
+ propertyPath: resolvedPropertyPath,
});
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({
animations,
- propertyPath,
+ propertyPath: resolvedPropertyPath,
time: localTime,
})
: null;
@@ -78,15 +82,12 @@ export function useKeyframedParamProperty({
updates: {
animations: upsertPathKeyframe({
animations,
- propertyPath,
+ propertyPath: resolvedPropertyPath,
time: localTime,
value,
- kind: getAnimationParamValueKind({ param }),
- defaultInterpolation: getAnimationParamDefaultInterpolation({
- param,
- }),
+ channelLayout: getParamChannelLayout({ param }),
coerceValue: ({ value: nextValue }) =>
- coerceAnimationParamValue({
+ coerceParamValue({
param,
value: nextValue,
}),
@@ -120,7 +121,7 @@ export function useKeyframedParamProperty({
{
trackId,
elementId,
- propertyPath,
+ propertyPath: resolvedPropertyPath,
keyframeId: keyframeIdAtTime,
},
],
@@ -133,7 +134,7 @@ export function useKeyframedParamProperty({
{
trackId,
elementId,
- propertyPath,
+ propertyPath: resolvedPropertyPath,
time: localTime,
value: resolvedValue,
},
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts
index 0e7ae6a7..7e8f6e40 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts
@@ -1,4 +1,4 @@
-import { useReducer, useRef } from "react";
+import { useState } from "react";
import { evaluateMathExpression } from "@/utils/math";
function looksLikeExpression({ input }: { input: string }): boolean {
@@ -14,59 +14,56 @@ export function usePropertyDraft<T>({
parse,
onPreview,
onCommit,
+ onStartEditing,
supportsExpressions = true,
}: {
displayValue: string;
parse: (input: string) => T | null;
onPreview: (value: T) => void;
onCommit: () => void;
+ onStartEditing?: () => void;
supportsExpressions?: boolean;
}) {
- const [, forceRender] = useReducer(
- (renderVersion: number) => renderVersion + 1,
- 0,
- );
- const isEditing = useRef(false);
- const draft = useRef("");
+ const [isEditing, setIsEditing] = useState(false);
+ const [draft, setDraft] = useState("");
return {
- displayValue: isEditing.current ? draft.current : sourceDisplay,
+ displayValue: isEditing ? draft : sourceDisplay,
scrubTo: (value: number) => {
const parsed = parse(String(value));
if (parsed !== null) onPreview(parsed);
},
commitScrub: onCommit,
onFocus: () => {
- isEditing.current = true;
- draft.current = sourceDisplay;
- forceRender();
+ setIsEditing(true);
+ setDraft(sourceDisplay);
+ onStartEditing?.();
},
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
- draft.current = event.target.value;
- forceRender();
+ const nextDraft = event.target.value;
+ setDraft(nextDraft);
- const parsed = parse(event.target.value);
+ const parsed = parse(nextDraft);
if (parsed !== null) {
onPreview(parsed);
}
},
- onBlur: () => {
- if (
- supportsExpressions &&
- looksLikeExpression({ input: draft.current })
- ) {
- const evaluated = evaluateMathExpression({ input: draft.current });
+ onBlur: (
+ event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
+ ) => {
+ const nextDraft = event.target.value;
+ if (supportsExpressions && looksLikeExpression({ input: nextDraft })) {
+ const evaluated = evaluateMathExpression({ input: nextDraft });
if (evaluated !== null) {
const parsed = parse(String(evaluated));
if (parsed !== null) onPreview(parsed);
}
}
onCommit();
- isEditing.current = false;
- draft.current = "";
- forceRender();
+ setIsEditing(false);
+ setDraft("");
},
};
}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
index 52fa42b5..d946027d 100644
--- a/apps/web/src/components/editor/panels/properties/index.tsx
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -71,7 +71,12 @@ export function PropertiesPanel() {
<Button
variant={tab.id === activeTab.id ? "secondary" : "ghost"}
size="icon"
- onClick={() => setActiveTab(element.type, tab.id)}
+ onClick={() =>
+ setActiveTab({
+ elementType: element.type,
+ tabId: tab.id,
+ })
+ }
aria-label={tab.label}
className={cn(
"shrink-0",
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx
index f9e40ab3..1eda6575 100644
--- a/apps/web/src/components/editor/panels/properties/registry.tsx
+++ b/apps/web/src/components/editor/panels/properties/registry.tsx
@@ -22,16 +22,43 @@ import {
MagicWand05Icon,
DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons";
-import { TransformTab } from "@/rendering/components/transform-tab";
-import { BlendingTab } from "@/rendering/components/blending-tab";
-import { AudioTab } from "@/timeline/components/audio-tab";
-import { TextTab } from "@/text/components/text-tab";
+import { ElementParamsTab } from "./components/element-params-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
import { MasksTab } from "@/masks/components/masks-tab";
import { SpeedTab } from "@/speed/components/speed-tab";
import { GraphicTab } from "@/graphics/components/graphic-tab";
import { OcShapesIcon } from "@/components/icons";
+const TRANSFORM_PARAM_KEYS = [
+ "transform.positionX",
+ "transform.positionY",
+ "transform.scaleX",
+ "transform.scaleY",
+ "transform.rotate",
+] as const;
+
+const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const;
+const AUDIO_PARAM_KEYS = ["volume", "muted"] as const;
+const TEXT_PARAM_KEYS = [
+ "content",
+ "fontFamily",
+ "fontSize",
+ "color",
+ "textAlign",
+ "fontWeight",
+ "fontStyle",
+ "textDecoration",
+ "letterSpacing",
+ "lineHeight",
+ "background.enabled",
+ "background.color",
+ "background.cornerRadius",
+ "background.paddingX",
+ "background.paddingY",
+ "background.offsetX",
+ "background.offsetY",
+] as const;
+
export type TabContentProps = {
trackId: string;
};
@@ -58,7 +85,12 @@ function buildTransformTab({
label: "Transform",
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
content: ({ trackId }) => (
- <TransformTab element={element} trackId={trackId} />
+ <ElementParamsTab
+ element={element}
+ trackId={trackId}
+ paramKeys={TRANSFORM_PARAM_KEYS}
+ sectionKey="transform"
+ />
),
};
}
@@ -73,7 +105,12 @@ function buildBlendingTab({
label: "Blending",
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
content: ({ trackId }) => (
- <BlendingTab element={element} trackId={trackId} />
+ <ElementParamsTab
+ element={element}
+ trackId={trackId}
+ paramKeys={BLENDING_PARAM_KEYS}
+ sectionKey="blending"
+ />
),
};
}
@@ -87,7 +124,14 @@ function buildAudioTab({
id: "audio",
label: "Audio",
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
- content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
+ content: ({ trackId }) => (
+ <ElementParamsTab
+ element={element}
+ trackId={trackId}
+ paramKeys={AUDIO_PARAM_KEYS}
+ sectionKey="audio"
+ />
+ ),
};
}
@@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
id: "text",
label: "Text",
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
- content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
+ content: ({ trackId }) => (
+ <ElementParamsTab
+ element={element}
+ trackId={trackId}
+ paramKeys={TEXT_PARAM_KEYS}
+ sectionKey="text"
+ />
+ ),
};
}
diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
index bd57d4b0..f6304349 100644
--- a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
+++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts
@@ -2,17 +2,18 @@ import { create } from "zustand";
interface PropertiesState {
activeTabPerType: Record<string, string>;
- setActiveTab: (elementType: string, tabId: string) => void;
+ setActiveTab: (args: { elementType: string; tabId: string }) => void;
isTransformScaleLocked: boolean;
- setTransformScaleLocked: (locked: boolean) => void;
+ setTransformScaleLocked: (args: { locked: boolean }) => void;
}
export const usePropertiesStore = create<PropertiesState>()((set) => ({
activeTabPerType: {},
- setActiveTab: (elementType, tabId) =>
+ setActiveTab: ({ elementType, tabId }) =>
set((state) => ({
activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
})),
isTransformScaleLocked: false,
- setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+ setTransformScaleLocked: ({ locked }) =>
+ set({ isTransformScaleLocked: locked }),
}));
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts
index 3e55e1d6..0977666f 100644
--- a/apps/web/src/core/index.ts
+++ b/apps/web/src/core/index.ts
@@ -39,7 +39,7 @@ export class EditorCore {
this.project = new ProjectManager(this);
this.media = new MediaManager(this);
this.renderer = new RendererManager(this);
- this.save = new SaveManager(this);
+ this.save = new SaveManager({ editor: this });
this.audio = new AudioManager(this);
this.selection = new SelectionManager(this);
this.clipboard = new ClipboardManager(this);
diff --git a/apps/web/src/core/managers/clipboard-manager.ts b/apps/web/src/core/managers/clipboard-manager.ts
index 8a2a51c9..283a78ae 100644
--- a/apps/web/src/core/managers/clipboard-manager.ts
+++ b/apps/web/src/core/managers/clipboard-manager.ts
@@ -6,6 +6,7 @@ import {
type CopyContext,
type PasteContext,
} from "@/clipboard";
+import type { MediaTime } from "@/wasm";
export class ClipboardManager {
private entry: ClipboardEntry | null = null;
@@ -34,7 +35,7 @@ export class ClipboardManager {
return true;
}
- paste({ time }: { time?: number } = {}): boolean {
+ paste({ time }: { time?: MediaTime } = {}): boolean {
if (!this.entry) {
return false;
}
@@ -64,7 +65,7 @@ export class ClipboardManager {
};
}
- private getPasteContext({ time }: { time?: number }): PasteContext {
+ private getPasteContext({ time }: { time?: MediaTime }): PasteContext {
return {
editor: this.editor,
selectedElements: this.editor.selection.getSelectedElements(),
diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts
index 553d1557..69da6e80 100644
--- a/apps/web/src/core/managers/media-manager.ts
+++ b/apps/web/src/core/managers/media-manager.ts
@@ -68,9 +68,17 @@ export class MediaManager {
const command =
uniqueIds.length === 1
- ? new RemoveMediaAssetCommand(projectId, uniqueIds[0])
+ ? new RemoveMediaAssetCommand({
+ projectId,
+ assetId: uniqueIds[0],
+ })
: new BatchCommand(
- uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)),
+ uniqueIds.map((id) =>
+ new RemoveMediaAssetCommand({
+ projectId,
+ assetId: id,
+ }),
+ ),
);
this.editor.command.execute({ command });
diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts
index a243dc19..9b9d26bc 100644
--- a/apps/web/src/core/managers/playback-manager.ts
+++ b/apps/web/src/core/managers/playback-manager.ts
@@ -1,20 +1,26 @@
import type { EditorCore } from "@/core";
-import { TICKS_PER_SECOND } from "@/wasm";
-import { roundToFrame } from "opencut-wasm";
+import {
+ addMediaTime,
+ clampMediaTime,
+ type MediaTime,
+ mediaTimeFromSeconds,
+ roundFrameTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
export class PlaybackManager {
private isPlaying = false;
- private currentTime = 0;
+ private currentTime: MediaTime = ZERO_MEDIA_TIME;
private volume = 1;
private muted = false;
private previousVolume = 1;
private isScrubbing = false;
private listeners = new Set<() => void>();
- private updateListeners = new Set<(time: number) => void>();
- private seekListeners = new Set<(time: number) => void>();
+ private updateListeners = new Set<(time: MediaTime) => void>();
+ private seekListeners = new Set<(time: MediaTime) => void>();
private playbackTimer: number | null = null;
private playbackStartWallTime = 0;
- private playbackStartTime = 0;
+ private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
private timelineScopeBound = false;
constructor(private editor: EditorCore) {}
@@ -40,7 +46,7 @@ export class PlaybackManager {
}
if (this.currentTime >= maxTime) {
- this.seek({ time: 0 });
+ this.seek({ time: ZERO_MEDIA_TIME });
}
this.isPlaying = true;
@@ -62,7 +68,7 @@ export class PlaybackManager {
}
}
- seek({ time }: { time: number }): void {
+ seek({ time }: { time: MediaTime }): void {
this.currentTime = this.clampTimeToTimeline(time);
if (this.isPlaying) {
this.playbackStartWallTime = performance.now();
@@ -109,7 +115,7 @@ export class PlaybackManager {
return this.isPlaying;
}
- getCurrentTime(): number {
+ getCurrentTime(): MediaTime {
return this.currentTime;
}
@@ -135,12 +141,12 @@ export class PlaybackManager {
return () => this.listeners.delete(listener);
}
- onUpdate(listener: (time: number) => void): () => void {
+ onUpdate(listener: (time: MediaTime) => void): () => void {
this.updateListeners.add(listener);
return () => this.updateListeners.delete(listener);
}
- onSeek(listener: (time: number) => void): () => void {
+ onSeek(listener: (time: MediaTime) => void): () => void {
this.seekListeners.add(listener);
return () => this.seekListeners.delete(listener);
}
@@ -165,6 +171,7 @@ export class PlaybackManager {
if (timeChanged) {
this.notifySeek(this.currentTime);
+ this.dispatchSeekEvent(this.currentTime);
}
}
@@ -174,6 +181,18 @@ export class PlaybackManager {
});
}
+ private notifyUpdate(time: MediaTime): void {
+ this.updateListeners.forEach((fn) => {
+ fn(time);
+ });
+ }
+
+ private notifySeek(time: MediaTime): void {
+ this.seekListeners.forEach((fn) => {
+ fn(time);
+ });
+ }
+
private startTimer(): void {
if (this.playbackTimer) {
cancelAnimationFrame(this.playbackTimer);
@@ -197,40 +216,42 @@ export class PlaybackManager {
const fps = this.editor.project.getActive()?.settings.fps;
const elapsedSeconds =
(performance.now() - this.playbackStartWallTime) / 1000;
- const rawTime =
- this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
- const newTime = fps
- ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
- : rawTime;
+ const rawTime = addMediaTime({
+ a: this.playbackStartTime,
+ b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
+ });
+ const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime;
const maxTime = this.editor.timeline.getTotalDuration();
if (newTime >= maxTime) {
this.pause();
this.currentTime = maxTime;
this.notify();
- this.notifySeek(maxTime);
- return;
+ this.notifySeek(maxTime);
+ this.dispatchSeekEvent(maxTime);
+ return;
}
this.currentTime = newTime;
this.notifyUpdate(newTime);
+ this.dispatchUpdateEvent(newTime);
this.playbackTimer = requestAnimationFrame(this.updateTime);
};
- private clampTimeToTimeline(time: number): number {
+ private clampTimeToTimeline(time: MediaTime): MediaTime {
const maxTime = this.editor.timeline.getTotalDuration();
- return Math.max(0, Math.min(maxTime, time));
+ return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime });
}
- private notifySeek(time: number): void {
- for (const fn of this.seekListeners) {
- fn(time);
+ private dispatchSeekEvent(time: MediaTime): void {
+ if (typeof window === "undefined") {
+ return;
}
}
- private notifyUpdate(time: number): void {
- for (const fn of this.updateListeners) {
- fn(time);
+ private dispatchUpdateEvent(time: MediaTime): void {
+ if (typeof window === "undefined") {
+ return;
}
}
}
diff --git a/apps/web/src/core/managers/save-manager.ts b/apps/web/src/core/managers/save-manager.ts
index b3c894d4..8cb2275a 100644
--- a/apps/web/src/core/managers/save-manager.ts
+++ b/apps/web/src/core/managers/save-manager.ts
@@ -12,13 +12,18 @@ export class SaveManager {
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeHandlers: Array<() => void> = [];
- constructor(
- private editor: EditorCore,
- { debounceMs = 800 }: SaveManagerOptions = {},
- ) {
+ constructor({
+ editor,
+ debounceMs = 800,
+ }: {
+ editor: EditorCore;
+ } & SaveManagerOptions) {
+ this.editor = editor;
this.debounceMs = debounceMs;
}
+ private editor: EditorCore;
+
start(): void {
if (this.unsubscribeHandlers.length > 0) return;
diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts
index 13f251a1..a4c364ce 100644
--- a/apps/web/src/core/managers/scenes-manager.ts
+++ b/apps/web/src/core/managers/scenes-manager.ts
@@ -1,5 +1,5 @@
import type { EditorCore } from "@/core";
-import type { SceneTracks, TScene } from "@/timeline";
+import type { Bookmark, SceneTracks, TScene } from "@/timeline";
import { storageService } from "@/services/storage/service";
import {
getMainScene,
@@ -21,6 +21,7 @@ import {
ToggleBookmarkCommand,
UpdateBookmarkCommand,
} from "@/commands/scene";
+import type { MediaTime } from "@/wasm";
export class ScenesManager {
private active: TScene | null = null;
@@ -40,7 +41,7 @@ export class ScenesManager {
throw new Error("No active project");
}
- const command = new CreateSceneCommand(name, isMain);
+ const command = new CreateSceneCommand({ name, isMain });
this.editor.command.execute({ command });
return command.getSceneId();
}
@@ -76,7 +77,10 @@ export class ScenesManager {
throw new Error("No active project");
}
- const command = new RenameSceneCommand(sceneId, name);
+ const command = new RenameSceneCommand({
+ sceneId,
+ newName: name,
+ });
this.editor.command.execute({ command });
}
@@ -106,12 +110,12 @@ export class ScenesManager {
this.notify();
}
- async toggleBookmark({ time }: { time: number }): Promise<void> {
+ async toggleBookmark({ time }: { time: MediaTime }): Promise<void> {
const command = new ToggleBookmarkCommand(time);
this.editor.command.execute({ command });
}
- isBookmarked({ time }: { time: number }): boolean {
+ isBookmarked({ time }: { time: MediaTime }): boolean {
const activeScene = this.getActiveScene();
const activeProject = this.editor.project.getActive();
@@ -125,7 +129,7 @@ export class ScenesManager {
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
}
- async removeBookmark({ time }: { time: number }): Promise<void> {
+ async removeBookmark({ time }: { time: MediaTime }): Promise<void> {
const command = new RemoveBookmarkCommand(time);
this.editor.command.execute({ command });
}
@@ -134,10 +138,10 @@ export class ScenesManager {
time,
updates,
}: {
- time: number;
- updates: Partial<{ note: string; color: string; duration: number }>;
+ time: MediaTime;
+ updates: Partial<Omit<Bookmark, "time">>;
}): Promise<void> {
- const command = new UpdateBookmarkCommand(time, updates);
+ const command = new UpdateBookmarkCommand({ time, updates });
this.editor.command.execute({ command });
}
@@ -145,14 +149,14 @@ export class ScenesManager {
fromTime,
toTime,
}: {
- fromTime: number;
- toTime: number;
+ fromTime: MediaTime;
+ toTime: MediaTime;
}): Promise<void> {
- const command = new MoveBookmarkCommand(fromTime, toTime);
+ const command = new MoveBookmarkCommand({ fromTime, toTime });
this.editor.command.execute({ command });
}
- getBookmarkAtTime({ time }: { time: number }) {
+ getBookmarkAtTime({ time }: { time: MediaTime }) {
const activeScene = this.active;
const activeProject = this.editor.project.getActive();
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index a2bf7451..6fb28625 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -11,23 +11,23 @@ import type {
import { calculateTotalDuration } from "@/timeline";
import { TimelineDragSource } from "@/timeline/drag-source";
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
+import { lastFrameMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
import {
canElementBeHidden,
canElementHaveAudio,
} from "@/timeline/element-utils";
+import { isElementMuted } from "@/timeline/audio-state";
import type {
AnimationPath,
AnimationInterpolation,
- AnimationValue,
- AnimationValueForPath,
ScalarCurveKeyframePatch,
} from "@/animation/types";
+import type { ParamValue } from "@/params";
import {
getElementLocalTime,
resolveAnimationPathValueAtTime,
} from "@/animation";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
-import { lastFrameTime } from "opencut-wasm";
import { BatchCommand } from "@/commands";
import {
AddTrackCommand,
@@ -46,8 +46,8 @@ import {
RetimeKeyframeCommand,
UpdateScalarKeyframeCurveCommand,
AddClipEffectCommand,
- DeleteCustomMaskPointsCommand,
- InsertCustomMaskPointCommand,
+ DeleteFreeformPathMaskPointsCommand,
+ InsertFreeformPathMaskPointCommand,
RemoveClipEffectCommand,
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
@@ -73,7 +73,7 @@ export class TimelineManager {
constructor(private editor: EditorCore) {}
addTrack({ type, index }: { type: TrackType; index?: number }): string {
- const command = new AddTrackCommand(type, index);
+ const command = new AddTrackCommand({ type, index });
this.editor.command.execute({ command });
return command.getTrackId();
}
@@ -97,10 +97,10 @@ export class TimelineManager {
pushHistory = true,
}: {
elementId: string;
- trimStart: number;
- trimEnd: number;
- startTime?: number;
- duration?: number;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+ startTime?: MediaTime;
+ duration?: MediaTime;
pushHistory?: boolean;
}): void {
const trackId = this.findTrackIdForElement({ elementId });
@@ -190,7 +190,7 @@ export class TimelineManager {
retainSide = "both",
}: {
elements: { trackId: string; elementId: string }[];
- splitTime: number;
+ splitTime: MediaTime;
retainSide?: "both" | "left" | "right";
}): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand({
@@ -202,20 +202,20 @@ export class TimelineManager {
return command.getRightSideElements();
}
- getTotalDuration(): number {
+ getTotalDuration(): MediaTime {
const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
return calculateTotalDuration({ tracks: activeScene.tracks });
}
- getLastFrameTime(): number {
+ getLastFrameTime(): MediaTime {
const duration = this.getTotalDuration();
const fps = this.editor.project.getActive()?.settings.fps;
if (!fps || duration <= 0) return duration;
- return lastFrameTime({ duration, rate: fps }) ?? duration;
+ return lastFrameMediaTime({ duration, fps });
}
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
@@ -349,7 +349,7 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
- deleteCustomMaskPoints({
+ deleteFreeformPathMaskPoints({
trackId,
elementId,
maskId,
@@ -363,7 +363,7 @@ export class TimelineManager {
if (pointIds.length === 0) {
return;
}
- const command = new DeleteCustomMaskPointsCommand({
+ const command = new DeleteFreeformPathMaskPointsCommand({
trackId,
elementId,
maskId,
@@ -372,7 +372,7 @@ export class TimelineManager {
this.editor.command.execute({ command });
}
- insertCustomMaskPoint({
+ insertFreeformPathMaskPoint({
trackId,
elementId,
maskId,
@@ -387,7 +387,7 @@ export class TimelineManager {
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
}): void {
- const command = new InsertCustomMaskPointCommand({
+ const command = new InsertFreeformPathMaskPointCommand({
trackId,
elementId,
maskId,
@@ -485,8 +485,8 @@ export class TimelineManager {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
- time: number;
- value: AnimationValue;
+ time: MediaTime;
+ value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}>;
@@ -537,7 +537,7 @@ export class TimelineManager {
// Pre-sample values at playhead for each (element, property) pair.
// This preserves "what you see is what you get" when all keyframes are deleted.
const playheadTime = this.editor.playback.getCurrentTime();
- const valueAtPlayheadMap = new Map<string, AnimationValue | null>();
+ const valueAtPlayheadMap = new Map<string, ParamValue | null>();
for (const { trackId, elementId, propertyPath } of keyframes) {
const key = `${elementId}:${propertyPath}`;
@@ -558,9 +558,7 @@ export class TimelineManager {
});
const target = resolveAnimationTarget({ element, path: propertyPath });
- const baseValue =
- (target?.getBaseValue() as AnimationValueForPath<AnimationPath> | null) ??
- null;
+ const baseValue = target?.getBaseValue() ?? null;
if (baseValue === null) {
valueAtPlayheadMap.set(key, null);
continue;
@@ -602,7 +600,7 @@ export class TimelineManager {
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
- time: number;
+ time: MediaTime;
}): void {
const command = new RetimeKeyframeCommand({
trackId,
@@ -660,7 +658,7 @@ export class TimelineManager {
elementId: string;
effectId: string;
paramKey: string;
- time: number;
+ time: MediaTime;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
@@ -709,9 +707,9 @@ export class TimelineManager {
updates,
}: {
updates: readonly {
- readonly trackId: string;
- readonly elementId: string;
- readonly updates: Partial<TimelineElement>;
+ trackId: string;
+ elementId: string;
+ updates: Partial<TimelineElement>;
}[];
}): void {
let changedOverlayCount = 0;
@@ -751,7 +749,10 @@ export class TimelineManager {
}
const afterTracks =
this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
- const command = new TracksSnapshotCommand(committedTracks, afterTracks);
+ const command = new TracksSnapshotCommand({
+ before: committedTracks,
+ after: afterTracks,
+ });
this.editor.command.push({ command });
this.previewOverlay.clear();
this.previewTracks = null;
@@ -840,7 +841,7 @@ export class TimelineManager {
}): void {
const shouldMute = elements.some(({ trackId, elementId }) => {
const element = this.getElementByRef({ trackId, elementId });
- return element && canElementHaveAudio(element) && !element.muted;
+ return element && canElementHaveAudio(element) && !isElementMuted({ element });
});
const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
@@ -853,7 +854,7 @@ export class TimelineManager {
{
trackId,
elementId,
- patch: { muted: shouldMute },
+ patch: { params: { muted: shouldMute } },
},
];
});
diff --git a/apps/web/src/editor/panel-store.ts b/apps/web/src/editor/panel-store.ts
index 2cb768a1..5a2bd0f4 100644
--- a/apps/web/src/editor/panel-store.ts
+++ b/apps/web/src/editor/panel-store.ts
@@ -14,7 +14,7 @@ export type PanelId = keyof PanelSizes;
interface PanelState {
panels: PanelSizes;
- setPanel: (panel: PanelId, size: number) => void;
+ setPanel: (args: { panel: PanelId; size: number }) => void;
setPanels: (sizes: Partial<PanelSizes>) => void;
resetPanels: () => void;
}
@@ -23,7 +23,7 @@ export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
...PANEL_CONFIG,
- setPanel: (panel, size) =>
+ setPanel: ({ panel, size }) =>
set((state) => ({
panels: {
...state.panels,
diff --git a/apps/web/src/editor/use-editor.ts b/apps/web/src/editor/use-editor.ts
index ce48e036..6380d470 100644
--- a/apps/web/src/editor/use-editor.ts
+++ b/apps/web/src/editor/use-editor.ts
@@ -1,7 +1,15 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { EditorCore } from "@/core";
-function isShallowEqual(a: unknown, b: unknown): boolean {
+const SNAPSHOT_UNSET = Symbol("snapshotUnset");
+
+function isShallowEqual({
+ a,
+ b,
+}: {
+ a: unknown;
+ b: unknown;
+}): boolean {
if (Object.is(a, b)) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
@@ -16,10 +24,7 @@ export function useEditor<T>(
selector?: (editor: EditorCore) => T,
): EditorCore | T {
const editor = useMemo(() => EditorCore.getInstance(), []);
- const selectorRef = useRef(selector);
- selectorRef.current = selector;
-
- const snapshotCacheRef = useRef<unknown>(undefined);
+ const snapshotCacheRef = useRef<T | typeof SNAPSHOT_UNSET>(SNAPSHOT_UNSET);
const subscribeAll = useCallback(
(onChange: () => void) => {
@@ -43,18 +48,29 @@ export function useEditor<T>(
[editor],
);
- const getSnapshot = useCallback(() => {
- const next = selectorRef.current ? selectorRef.current(editor) : editor;
- if (isShallowEqual(snapshotCacheRef.current, next)) {
+ const getSnapshot = useCallback((): EditorCore | T => {
+ if (!selector) {
+ return editor;
+ }
+
+ const next = selector(editor);
+ if (
+ snapshotCacheRef.current !== SNAPSHOT_UNSET &&
+ isShallowEqual({
+ a: snapshotCacheRef.current,
+ b: next,
+ })
+ ) {
return snapshotCacheRef.current;
}
+
snapshotCacheRef.current = next;
return next;
- }, [editor]);
+ }, [editor, selector]);
return useSyncExternalStore(
selector ? subscribeAll : subscribeNone,
getSnapshot,
getSnapshot,
- ) as EditorCore | T;
+ );
}
diff --git a/apps/web/src/effects/definitions/index.ts b/apps/web/src/effects/definitions/index.ts
index edc8c5e2..511df042 100644
--- a/apps/web/src/effects/definitions/index.ts
+++ b/apps/web/src/effects/definitions/index.ts
@@ -8,6 +8,9 @@ export function registerDefaultEffects(): void {
if (effectsRegistry.has(definition.type)) {
continue;
}
- effectsRegistry.register(definition.type, definition);
+ effectsRegistry.register({
+ key: definition.type,
+ definition,
+ });
}
}
diff --git a/apps/web/src/fps/utils.ts b/apps/web/src/fps/utils.ts
index e30e263a..c2a57fbb 100644
--- a/apps/web/src/fps/utils.ts
+++ b/apps/web/src/fps/utils.ts
@@ -22,7 +22,13 @@ export function frameRateToFloat(rate: FrameRate): number {
return rate.numerator / rate.denominator;
}
-export function frameRatesEqual(a: FrameRate, b: FrameRate): boolean {
+export function frameRatesEqual({
+ a,
+ b,
+}: {
+ a: FrameRate;
+ b: FrameRate;
+}): boolean {
return a.numerator === b.numerator && a.denominator === b.denominator;
}
@@ -38,14 +44,17 @@ export function floatToFrameRate(fps: number): FrameRate {
const ARBITRARY_DENOMINATOR = 1_000_000;
const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR);
- const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR);
+ const divisor = gcd({
+ left: scaledNumerator,
+ right: ARBITRARY_DENOMINATOR,
+ });
return {
numerator: scaledNumerator / divisor,
denominator: ARBITRARY_DENOMINATOR / divisor,
};
}
-function gcd(left: number, right: number): number {
+function gcd({ left, right }: { left: number; right: number }): number {
let a = Math.abs(left);
let b = Math.abs(right);
while (b !== 0) {
diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx
index e0ae0236..31c4a86d 100644
--- a/apps/web/src/graphics/components/graphic-tab.tsx
+++ b/apps/web/src/graphics/components/graphic-tab.tsx
@@ -8,11 +8,7 @@ import {
} from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
import type { ParamDefinition, ParamValues } from "@/params";
import type { GraphicElement } from "@/timeline";
-import {
- graphicsRegistry,
- registerDefaultGraphics,
- resolveGraphicElementParamsAtTime,
-} from "@/graphics";
+import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
import { useEditor } from "@/editor/use-editor";
import {
@@ -27,6 +23,7 @@ import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
+import type { MediaTime } from "@/wasm";
registerDefaultGraphics();
@@ -200,10 +197,11 @@ function AnimatedGraphicParamField({
isPlayheadWithinElementRange,
resolvedParams,
}: {
+ key?: string;
param: ParamDefinition;
trackId: string;
element: GraphicElement;
- localTime: number;
+ localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
resolvedParams: ParamValues;
}) {
diff --git a/apps/web/src/graphics/definitions/index.ts b/apps/web/src/graphics/definitions/index.ts
index 6fd9e446..846a9983 100644
--- a/apps/web/src/graphics/definitions/index.ts
+++ b/apps/web/src/graphics/definitions/index.ts
@@ -16,7 +16,10 @@ export function registerDefaultGraphics(): void {
if (graphicsRegistry.has(definition.id)) {
continue;
}
- graphicsRegistry.register(definition.id, definition);
+ graphicsRegistry.register({
+ key: definition.id,
+ definition,
+ });
}
}
diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts
index 7f5ac08b..7b8d905e 100644
--- a/apps/web/src/graphics/index.ts
+++ b/apps/web/src/graphics/index.ts
@@ -60,10 +60,13 @@ export function buildDefaultGraphicInstance({
};
}
-export function resolveGraphicParams(
- definition: GraphicDefinition,
- params?: ParamValues,
-): ParamValues {
+export function resolveGraphicParams({
+ definition,
+ params,
+}: {
+ definition: GraphicDefinition;
+ params?: ParamValues;
+}): ParamValues {
return {
...buildDefaultParamValues(definition.params),
...(params ?? {}),
@@ -85,7 +88,10 @@ export function resolveGraphicElementParamsAtTime({
definitionId: element.definitionId,
});
return resolveGraphicParamsAtTime({
- params: resolveGraphicParams(definition, element.params),
+ params: resolveGraphicParams({
+ definition,
+ params: element.params,
+ }),
definitions: definition.params,
animations: element.animations,
localTime,
@@ -102,7 +108,7 @@ export function buildGraphicPreviewUrl({
size?: number;
}): string {
const definition = getGraphicDefinition({ definitionId });
- const resolvedParams = resolveGraphicParams(definition, params);
+ const resolvedParams = resolveGraphicParams({ definition, params });
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
if (cachedUrl) {
diff --git a/apps/web/src/hooks/use-committed-ref.ts b/apps/web/src/hooks/use-committed-ref.ts
new file mode 100644
index 00000000..ff064985
--- /dev/null
+++ b/apps/web/src/hooks/use-committed-ref.ts
@@ -0,0 +1,11 @@
+import { useLayoutEffect, useRef } from "react";
+
+export function useCommittedRef<T>(value: T) {
+ const ref = useRef(value);
+
+ useLayoutEffect(() => {
+ ref.current = value;
+ }, [value]);
+
+ return ref;
+}
diff --git a/apps/web/src/hooks/use-focus-lock.ts b/apps/web/src/hooks/use-focus-lock.ts
index 236d2623..86092aec 100644
--- a/apps/web/src/hooks/use-focus-lock.ts
+++ b/apps/web/src/hooks/use-focus-lock.ts
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
@@ -37,8 +38,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({
allowSelector?: string;
}) {
const containerRef = useRef<T>(null);
- const onDismissRef = useRef(onDismiss);
- onDismissRef.current = onDismiss;
+ const onDismissRef = useCommittedRef(onDismiss);
useEffect(() => {
if (!isActive) return;
@@ -53,10 +53,13 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({
const handleOutsidePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
- if (container.contains(event.target as Node)) return;
+ const target = event.target;
+ if (target instanceof Node && container.contains(target)) return;
- const target = event.target as Element | null;
- const isAllowedTarget = allowSelector && target?.closest(allowSelector);
+ const isAllowedTarget =
+ allowSelector &&
+ target instanceof Element &&
+ target.closest(allowSelector);
if (isAllowedTarget) return;
onDismissRef.current();
@@ -73,7 +76,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({
container.removeAttribute(DATA_ATTR);
focusLockStyle.remove();
};
- }, [isActive, cursor, allowSelector]);
+ }, [isActive, cursor, allowSelector, onDismissRef]);
return { containerRef };
}
diff --git a/apps/web/src/masks/__tests__/snap.test.ts b/apps/web/src/masks/__tests__/snap.test.ts
index 2f33615f..a5d0ceb3 100644
--- a/apps/web/src/masks/__tests__/snap.test.ts
+++ b/apps/web/src/masks/__tests__/snap.test.ts
@@ -1,22 +1,22 @@
import { describe, expect, test } from "bun:test";
import {
- findClosestPointOnCustomMaskSegment,
- getCustomMaskClosedStateAfterPointRemoval,
- insertPointIntoCustomMaskSegment,
- removeCustomMaskPoints,
-} from "@/masks/custom-path";
+ findClosestPointOnFreeformSegment,
+ getFreeformPathClosedStateAfterPointRemoval,
+ insertPointIntoFreeformSegment,
+ removeFreeformPathPoints,
+} from "@/masks/freeform/path";
import {
- appendPointToCustomMask,
- customMaskDefinition,
- insertPointOnCustomMaskSegment,
-} from "@/masks/definitions/custom";
-import { getSplitMaskStrokeSegment } from "@/masks/definitions/split";
-import { textMaskDefinition } from "@/masks/definitions/text";
+ appendPointToFreeformPathMask,
+ freeformMaskDefinition,
+ insertPointOnFreeformSegment,
+} from "@/masks/freeform/definition";
+import { getSplitMaskStrokeSegment } from "@/masks/builtin/definitions/split";
+import { textMaskDefinition } from "@/masks/builtin/definitions/text";
import { getMaskSnapGeometry } from "@/masks/geometry";
-import { snapMaskInteraction } from "@/masks/snap";
+import { snapBoxMaskInteraction, snapSplitMaskInteraction } from "@/masks/snap";
import type { ElementBounds } from "@/preview/element-bounds";
import type {
- CustomMaskParams,
+ FreeformPathMaskParams,
RectangleMaskParams,
SplitMaskParams,
TextMaskParams,
@@ -100,9 +100,9 @@ function buildTextMaskParams(
};
}
-function buildCustomMaskParams(
- overrides: Partial<CustomMaskParams> = {},
-): CustomMaskParams {
+function buildFreeformPathMaskParams(
+ overrides: Partial<FreeformPathMaskParams> = {},
+): FreeformPathMaskParams {
return {
feather: 0,
inverted: false,
@@ -150,10 +150,10 @@ function buildCustomMaskParams(
function sortSegment(
segment: [{ x: number; y: number }, { x: number; y: number }],
): [{ x: number; y: number }, { x: number; y: number }] {
- return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
- { x: number; y: number },
- { x: number; y: number },
- ];
+ const [first, second] = segment;
+ return first.x < second.x || (first.x === second.x && first.y <= second.y)
+ ? [first, second]
+ : [second, first];
}
describe("mask geometry", () => {
@@ -230,8 +230,8 @@ describe("mask geometry", () => {
describe("mask snapping", () => {
test("snaps split mask movement using the shared position pipeline", () => {
- const result = snapMaskInteraction({
- handleId: "position",
+ const result = snapSplitMaskInteraction({
+ handleId: { kind: "position" },
startParams: buildSplitParams({
centerX: 0.03,
centerY: -0.04,
@@ -254,8 +254,8 @@ describe("mask snapping", () => {
});
test("snaps box mask movement against element center and edges", () => {
- const result = snapMaskInteraction({
- handleId: "position",
+ const result = snapBoxMaskInteraction({
+ handleId: { kind: "position" },
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
centerX: 0.29,
@@ -275,8 +275,8 @@ describe("mask snapping", () => {
});
test("snaps mask rotation through the shared rotation path", () => {
- const result = snapMaskInteraction({
- handleId: "rotation",
+ const result = snapBoxMaskInteraction({
+ handleId: { kind: "rotation" },
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
rotation: 88,
@@ -291,8 +291,8 @@ describe("mask snapping", () => {
});
test("snaps edge resize for box masks", () => {
- const result = snapMaskInteraction({
- handleId: "right",
+ const result = snapBoxMaskInteraction({
+ handleId: { kind: "edge", side: "right" },
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
width: 0.98,
@@ -307,8 +307,8 @@ describe("mask snapping", () => {
});
test("snaps corner resize for box masks", () => {
- const result = snapMaskInteraction({
- handleId: "bottom-right",
+ const result = snapBoxMaskInteraction({
+ handleId: { kind: "corner", corner: { x: "right", y: "bottom" } },
startParams: buildRectangleParams(),
proposedParams: buildRectangleParams({
width: 0.99,
@@ -324,13 +324,49 @@ describe("mask snapping", () => {
expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
});
+ test("snaps vertical edge resize for box masks", () => {
+ // bounds.height=100 → localCanvasSize.height/2=50 is the bottom snap target.
+ // height=0.2 → baseHeight=20 → aabbHalfH=10.
+ // At height=0.98 → proposedScaleY=4.9 → bottomEdge=0+10*4.9=49.
+ // |49-50|=1 < threshold(8) → snaps to height=0.2*5=1.0; line at position 50.
+ const result = snapBoxMaskInteraction({
+ handleId: { kind: "edge", side: "bottom" },
+ startParams: buildRectangleParams(),
+ proposedParams: buildRectangleParams({ height: 0.98 }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.height).toBe(1);
+ expect(result.activeLines).toEqual([{ type: "horizontal", position: 50 }]);
+ });
+
+ test("snaps uniform scale handle for box masks", () => {
+ // bounds.width=200 → localCanvasSize.width/2=100 is the right snap target.
+ // width=0.4, scale=1 → aabbHalfW = (0.4*200)/2 * 1 = 40.
+ // At scale=2.48 → rightEdge=0+40*2.48=99.2; |99.2-100|=0.8 < threshold(8)
+ // → snaps to scale=1*(100/40)=2.5; line at position 100.
+ const result = snapBoxMaskInteraction({
+ handleId: { kind: "scale" },
+ startParams: buildRectangleParams({ scale: 1 }),
+ proposedParams: buildRectangleParams({ scale: 2.48 }),
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+
+ expect(result.params.scale).toBe(2.5);
+ expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
+ });
+
test("snaps text mask movement using intrinsic text bounds", () => {
const params = buildTextMaskParams({
centerX: 0.03,
centerY: -0.04,
});
const result = textMaskDefinition.interaction.snap?.({
- handleId: "position",
+ handleId: { kind: "position" },
startParams: params,
proposedParams: params,
bounds,
@@ -347,12 +383,12 @@ describe("mask snapping", () => {
});
test("snaps custom mask movement using path geometry bounds", () => {
- const params = buildCustomMaskParams({
+ const params = buildFreeformPathMaskParams({
centerX: 0.03,
centerY: -0.04,
});
- const result = customMaskDefinition.interaction.snap?.({
- handleId: "position",
+ const result = freeformMaskDefinition.interaction.snap?.({
+ handleId: { kind: "position" },
startParams: params,
proposedParams: params,
bounds,
@@ -381,11 +417,11 @@ describe("mask snapping", () => {
describe("custom mask creation", () => {
test("anchors the first point at the click position", () => {
- const params = buildCustomMaskParams({
+ const params = buildFreeformPathMaskParams({
path: [],
closed: false,
});
- const next = appendPointToCustomMask({
+ const next = appendPointToFreeformPathMask({
params,
canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
bounds,
@@ -401,8 +437,8 @@ describe("custom mask creation", () => {
describe("custom mask point deletion", () => {
test("removes the selected points by id", () => {
- const points = buildCustomMaskParams().path;
- const nextPoints = removeCustomMaskPoints({
+ const points = buildFreeformPathMaskParams().path;
+ const nextPoints = removeFreeformPathPoints({
points,
pointIds: ["b"],
});
@@ -411,14 +447,14 @@ describe("custom mask point deletion", () => {
});
test("reopens a closed path once fewer than three points remain", () => {
- const points = buildCustomMaskParams().path;
- const nextPoints = removeCustomMaskPoints({
+ const points = buildFreeformPathMaskParams().path;
+ const nextPoints = removeFreeformPathPoints({
points,
pointIds: ["c"],
});
expect(
- getCustomMaskClosedStateAfterPointRemoval({
+ getFreeformPathClosedStateAfterPointRemoval({
wasClosed: true,
remainingPointCount: nextPoints.length,
}),
@@ -428,9 +464,9 @@ describe("custom mask point deletion", () => {
describe("custom mask point insertion", () => {
test("finds the closest point on the clicked segment", () => {
- const params = buildCustomMaskParams();
+ const params = buildFreeformPathMaskParams();
const points = params.path;
- const closestPoint = findClosestPointOnCustomMaskSegment({
+ const closestPoint = findClosestPointOnFreeformSegment({
points,
segmentIndex: 0,
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
@@ -449,8 +485,8 @@ describe("custom mask point insertion", () => {
});
test("splits a segment into two segments at the insertion point", () => {
- const points = buildCustomMaskParams().path;
- const nextPoints = insertPointIntoCustomMaskSegment({
+ const points = buildFreeformPathMaskParams().path;
+ const nextPoints = insertPointIntoFreeformSegment({
points,
segmentIndex: 0,
pointId: "new",
@@ -471,8 +507,8 @@ describe("custom mask point insertion", () => {
});
test("builds updated custom mask params for a clicked segment", () => {
- const result = insertPointOnCustomMaskSegment({
- params: buildCustomMaskParams(),
+ const result = insertPointOnFreeformSegment({
+ params: buildFreeformPathMaskParams(),
segmentIndex: 0,
canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
bounds,
diff --git a/apps/web/src/masks/definitions/box-like.ts b/apps/web/src/masks/builtin/box-like.ts
index 7db51df7..37706755 100644
--- a/apps/web/src/masks/definitions/box-like.ts
+++ b/apps/web/src/masks/builtin/box-like.ts
@@ -16,7 +16,7 @@ import {
getBoxMaskHandlePositions,
getBoxMaskOverlays,
} from "@/masks/handle-positions";
-import { snapMaskInteraction } from "@/masks/snap";
+import { snapBoxMaskInteraction } from "@/masks/snap";
const PERCENTAGE_DISPLAY: Pick<
NumberParamDefinition,
@@ -198,7 +198,7 @@ export function buildBoxMaskInteraction({
};
},
snap(args) {
- return snapMaskInteraction(args);
+ return snapBoxMaskInteraction(args);
},
};
}
@@ -210,20 +210,20 @@ export function computeBoxMaskParamUpdate({
deltaY,
bounds,
}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
- if (handleId === "position") {
+ if (handleId.kind === "position") {
return {
centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height,
};
}
- if (handleId === "rotation") {
+ if (handleId.kind === "rotation") {
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
const newRotation = (startParams.rotation + currentAngle) % 360;
return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
}
- if (handleId === "feather") {
+ if (handleId.kind === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({
startFeather: startParams.feather,
@@ -237,8 +237,11 @@ export function computeBoxMaskParamUpdate({
const halfWidth = startParams.width * bounds.width;
const halfHeight = startParams.height * bounds.height;
- if (handleId === "right" || handleId === "left") {
- const sign = handleId === "right" ? 1 : -1;
+ if (
+ handleId.kind === "edge" &&
+ (handleId.side === "right" || handleId.side === "left")
+ ) {
+ const sign = handleId.side === "right" ? 1 : -1;
return {
width: Math.max(
MIN_MASK_DIMENSION,
@@ -247,8 +250,11 @@ export function computeBoxMaskParamUpdate({
};
}
- if (handleId === "bottom" || handleId === "top") {
- const sign = handleId === "bottom" ? 1 : -1;
+ if (
+ handleId.kind === "edge" &&
+ (handleId.side === "bottom" || handleId.side === "top")
+ ) {
+ const sign = handleId.side === "bottom" ? 1 : -1;
return {
height: Math.max(
MIN_MASK_DIMENSION,
@@ -257,14 +263,9 @@ export function computeBoxMaskParamUpdate({
};
}
- if (
- handleId === "top-left" ||
- handleId === "top-right" ||
- handleId === "bottom-left" ||
- handleId === "bottom-right"
- ) {
- const signX = handleId.includes("right") ? 1 : -1;
- const signY = handleId.includes("bottom") ? 1 : -1;
+ if (handleId.kind === "corner") {
+ const signX = handleId.corner.x === "right" ? 1 : -1;
+ const signY = handleId.corner.y === "bottom" ? 1 : -1;
const distance = Math.sqrt(
(signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
);
@@ -276,7 +277,7 @@ export function computeBoxMaskParamUpdate({
};
}
- if (handleId === "scale") {
+ if (handleId.kind === "scale") {
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
diff --git a/apps/web/src/masks/definitions/cinematic-bars.ts b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts
index 36b31328..0e23fd58 100644
--- a/apps/web/src/masks/definitions/cinematic-bars.ts
+++ b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts
@@ -10,7 +10,7 @@ import {
getDefaultBaseMaskParams,
getStrokeOffset,
rotatePoint,
-} from "./box-like";
+} from "../box-like";
function getDefaultCinematicBarsMaskParams({
elementSize,
@@ -71,7 +71,7 @@ function buildBandPath({
return path;
}
-export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
+export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = {
type: "cinematic-bars",
name: "Cinematic Bars",
features: {
@@ -94,42 +94,51 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> =
},
computeParamUpdate: computeBoxMaskParamUpdate,
renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const centerX = width / 2 + params.centerX * width;
- const centerY = height / 2 + params.centerY * height;
- const maskWidth = Math.max(params.width * width, width);
- const maskHeight = Math.max(params.height, 0.01) * height;
- const rotationRad = (params.rotation * Math.PI) / 180;
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const centerX = width / 2 + params.centerX * width;
+ const centerY = height / 2 + params.centerY * height;
+ const maskWidth = Math.max(params.width * width, width);
+ const maskHeight = Math.max(params.height, 0.01) * height;
+ const rotationRad = (params.rotation * Math.PI) / 180;
- return buildBandPath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
+ return buildBandPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
},
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const centerX = width / 2 + params.centerX * width;
- const centerY = height / 2 + params.centerY * height;
- const rotationRad = (params.rotation * Math.PI) / 180;
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const centerX = width / 2 + params.centerX * width;
+ const centerY = height / 2 + params.centerY * height;
+ const rotationRad = (params.rotation * Math.PI) / 180;
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
- return buildBandPath({
- centerX,
- centerY,
- halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1),
- halfHeight: Math.max(
- (Math.max(params.height, 0.01) * height) / 2 + offset,
- 1,
- ),
- rotationRad,
- });
+ return buildBandPath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(
+ Math.max(params.width * width, width) / 2 + offset,
+ 1,
+ ),
+ halfHeight: Math.max(
+ (Math.max(params.height, 0.01) * height) / 2 + offset,
+ 1,
+ ),
+ rotationRad,
+ });
+ },
},
},
};
diff --git a/apps/web/src/masks/definitions/diamond.ts b/apps/web/src/masks/builtin/definitions/diamond.ts
index 037aee70..1effb276 100644
--- a/apps/web/src/masks/definitions/diamond.ts
+++ b/apps/web/src/masks/builtin/definitions/diamond.ts
@@ -1,4 +1,4 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
+import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
@@ -7,7 +7,7 @@ import {
getDefaultSquareMaskParams,
getStrokeOffset,
rotatePoint,
-} from "./box-like";
+} from "../box-like";
function buildDiamondPath({
centerX,
@@ -45,7 +45,7 @@ function buildDiamondPath({
return path;
}
-export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
+export const diamondMaskDefinition: MaskDefinition<"diamond"> = {
type: "diamond",
name: "Diamond",
features: {
@@ -68,33 +68,39 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
},
computeParamUpdate: computeBoxMaskParamUpdate,
renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- return buildDiamondPath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildDiamondPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
},
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- return buildDiamondPath({
- centerX,
- centerY,
- halfWidth: Math.max(maskWidth / 2 + offset, 1),
- halfHeight: Math.max(maskHeight / 2 + offset, 1),
- rotationRad,
- });
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ return buildDiamondPath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(maskWidth / 2 + offset, 1),
+ halfHeight: Math.max(maskHeight / 2 + offset, 1),
+ rotationRad,
+ });
+ },
},
},
};
diff --git a/apps/web/src/masks/builtin/definitions/ellipse.ts b/apps/web/src/masks/builtin/definitions/ellipse.ts
new file mode 100644
index 00000000..688a2fa6
--- /dev/null
+++ b/apps/web/src/masks/builtin/definitions/ellipse.ts
@@ -0,0 +1,81 @@
+import type { MaskDefinition } from "@/masks/types";
+import {
+ BOX_LIKE_MASK_PARAMS,
+ buildBoxMaskInteraction,
+ computeBoxMaskParamUpdate,
+ getBoxLikeGeometry,
+ getDefaultSquareMaskParams,
+ getStrokeOffset,
+} from "../box-like";
+
+export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = {
+ type: "ellipse",
+ name: "Ellipse",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "width-height",
+ },
+ params: BOX_LIKE_MASK_PARAMS,
+ interaction: buildBoxMaskInteraction({
+ sizeMode: "width-height",
+ buildOverlayPath({ width, height }) {
+ const rx = Math.max((width - 1) / 2, 0);
+ const ry = Math.max((height - 1) / 2, 0);
+ const cx = width / 2;
+ const cy = height / 2;
+ return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
+ },
+ }),
+ buildDefault(context) {
+ return {
+ type: "ellipse",
+ params: getDefaultSquareMaskParams(context),
+ };
+ },
+ computeParamUpdate: computeBoxMaskParamUpdate,
+ renderer: {
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const path = new Path2D();
+ path.ellipse(
+ centerX,
+ centerY,
+ maskWidth / 2,
+ maskHeight / 2,
+ rotationRad,
+ 0,
+ Math.PI * 2,
+ );
+ return path;
+ },
+ },
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ const path = new Path2D();
+ path.ellipse(
+ centerX,
+ centerY,
+ Math.max(1, maskWidth / 2 + offset),
+ Math.max(1, maskHeight / 2 + offset),
+ rotationRad,
+ 0,
+ Math.PI * 2,
+ );
+ return path;
+ },
+ },
+ },
+};
diff --git a/apps/web/src/masks/definitions/heart.ts b/apps/web/src/masks/builtin/definitions/heart.ts
index d15c09e6..d1af5eb2 100644
--- a/apps/web/src/masks/definitions/heart.ts
+++ b/apps/web/src/masks/builtin/definitions/heart.ts
@@ -1,4 +1,4 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
+import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
@@ -7,7 +7,7 @@ import {
getDefaultSquareMaskParams,
getStrokeOffset,
rotatePoint,
-} from "./box-like";
+} from "../box-like";
function buildHeartPath({
centerX,
@@ -78,7 +78,7 @@ function buildHeartPath({
return path;
}
-export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
+export const heartMaskDefinition: MaskDefinition<"heart"> = {
type: "heart",
name: "Heart",
features: {
@@ -110,33 +110,39 @@ export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
},
computeParamUpdate: computeBoxMaskParamUpdate,
renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- return buildHeartPath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildHeartPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
},
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- return buildHeartPath({
- centerX,
- centerY,
- halfWidth: Math.max(maskWidth / 2 + offset, 1),
- halfHeight: Math.max(maskHeight / 2 + offset, 1),
- rotationRad,
- });
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ return buildHeartPath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(maskWidth / 2 + offset, 1),
+ halfHeight: Math.max(maskHeight / 2 + offset, 1),
+ rotationRad,
+ });
+ },
},
},
};
diff --git a/apps/web/src/masks/definitions/index.ts b/apps/web/src/masks/builtin/definitions/index.ts
index 9cee1020..5deb4e3f 100644
--- a/apps/web/src/masks/definitions/index.ts
+++ b/apps/web/src/masks/builtin/definitions/index.ts
@@ -1,7 +1,9 @@
-import type { BaseMaskParams, MaskDefinition } from "@/masks/types";
-import { masksRegistry, type MaskIconProps } from "../registry";
+import {
+ masksRegistry,
+ type MaskDefinitionForRegistration,
+ type MaskIconProps,
+} from "../../registry";
import { cinematicBarsMaskDefinition } from "./cinematic-bars";
-import { customMaskDefinition } from "./custom";
import { diamondMaskDefinition } from "./diamond";
import { ellipseMaskDefinition } from "./ellipse";
import { heartMaskDefinition } from "./heart";
@@ -9,6 +11,7 @@ import { rectangleMaskDefinition } from "./rectangle";
import { splitMaskDefinition } from "./split";
import { starMaskDefinition } from "./star";
import { textMaskDefinition } from "./text";
+import { freeformMaskDefinition } from "../../freeform/definition";
import {
MinusSignIcon,
PanelRightDashedIcon,
@@ -18,13 +21,14 @@ import {
DiamondIcon,
StarsIcon,
TextFontIcon,
+ PenToolAddIcon,
} from "@hugeicons/core-free-icons";
-function registerDefaultMask<TParams extends BaseMaskParams>({
+function registerDefaultMask({
definition,
icon,
}: {
- definition: MaskDefinition<TParams>;
+ definition: MaskDefinitionForRegistration;
icon: MaskIconProps;
}) {
if (masksRegistry.has(definition.type)) {
@@ -68,7 +72,7 @@ export function registerDefaultMasks(): void {
icon: { icon: TextFontIcon },
});
registerDefaultMask({
- definition: customMaskDefinition,
- icon: { icon: SquareIcon },
+ definition: freeformMaskDefinition,
+ icon: { icon: PenToolAddIcon },
});
}
diff --git a/apps/web/src/masks/definitions/rectangle.ts b/apps/web/src/masks/builtin/definitions/rectangle.ts
index 7bf1a7fd..4dd38154 100644
--- a/apps/web/src/masks/definitions/rectangle.ts
+++ b/apps/web/src/masks/builtin/definitions/rectangle.ts
@@ -1,4 +1,4 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
+import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
@@ -7,7 +7,7 @@ import {
getDefaultSquareMaskParams,
getStrokeOffset,
rotatePoint,
-} from "./box-like";
+} from "../box-like";
function buildRectanglePath({
centerX,
@@ -45,7 +45,7 @@ function buildRectanglePath({
return path;
}
-export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
+export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = {
type: "rectangle",
name: "Rectangle",
features: {
@@ -65,33 +65,39 @@ export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
},
computeParamUpdate: computeBoxMaskParamUpdate,
renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- return buildRectanglePath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
},
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- return buildRectanglePath({
- centerX,
- centerY,
- halfWidth: Math.max(1, maskWidth / 2 + offset),
- halfHeight: Math.max(1, maskHeight / 2 + offset),
- rotationRad,
- });
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ return buildRectanglePath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(1, maskWidth / 2 + offset),
+ halfHeight: Math.max(1, maskHeight / 2 + offset),
+ rotationRad,
+ });
+ },
},
},
};
diff --git a/apps/web/src/masks/definitions/split.ts b/apps/web/src/masks/builtin/definitions/split.ts
index d50a2571..13dd1c09 100644
--- a/apps/web/src/masks/definitions/split.ts
+++ b/apps/web/src/masks/builtin/definitions/split.ts
@@ -1,15 +1,15 @@
-import { computeFeatherUpdate } from "../param-update";
+import { computeFeatherUpdate } from "@/masks/param-update";
import type {
MaskDefinition,
MaskParamUpdateArgs,
SplitMaskParams,
} from "@/masks/types";
-import { halfPlaneSign, lineEdgeIntersection } from "../utils";
+import { halfPlaneSign, lineEdgeIntersection } from "@/masks/utils";
import {
getLineMaskHandlePositions,
getLineMaskOverlay,
} from "@/masks/handle-positions";
-import { snapMaskInteraction } from "@/masks/snap";
+import { snapSplitMaskInteraction } from "@/masks/snap";
// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
@@ -53,10 +53,13 @@ function splitLineGeometry({
return { normalX, normalY, lineX, lineY };
}
-function pointsEqual(
- a: { x: number; y: number },
- b: { x: number; y: number },
-): boolean {
+function pointsEqual({
+ a,
+ b,
+}: {
+ a: { x: number; y: number };
+ b: { x: number; y: number };
+}): boolean {
return (
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
@@ -68,11 +71,11 @@ export function getSplitMaskStrokeSegment({
width,
height,
}: {
- resolvedParams: unknown;
+ resolvedParams: SplitMaskParams;
width: number;
height: number;
}): [{ x: number; y: number }, { x: number; y: number }] | null {
- const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
+ const { centerX, centerY, rotation } = resolvedParams;
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
centerX,
centerY,
@@ -101,7 +104,7 @@ export function getSplitMaskStrokeSegment({
y2,
});
- if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
+ if (!hit || intersections.some((point) => pointsEqual({ a: point, b: hit }))) {
continue;
}
@@ -125,7 +128,7 @@ function computeSplitMaskParamUpdate({
bounds,
canvasSize,
}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
- if (handleId === "position") {
+ if (handleId.kind === "position") {
const rawX = startParams.centerX + deltaX / bounds.width;
const rawY = startParams.centerY + deltaY / bounds.height;
@@ -140,7 +143,7 @@ function computeSplitMaskParamUpdate({
};
}
- if (handleId === "feather") {
+ if (handleId.kind === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({
startFeather: startParams.feather,
@@ -151,7 +154,7 @@ function computeSplitMaskParamUpdate({
});
}
- if (handleId === "rotation") {
+ if (handleId.kind === "rotation") {
const pivotX = bounds.cx + startParams.centerX * bounds.width;
const pivotY = bounds.cy + startParams.centerY * bounds.height;
const startAngle =
@@ -177,7 +180,7 @@ function computeSplitMaskParamUpdate({
return {};
}
-export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
+export const splitMaskDefinition: MaskDefinition<"split"> = {
type: "split",
name: "Split",
features: {
@@ -213,7 +216,7 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
};
},
snap(args) {
- return snapMaskInteraction(args);
+ return snapSplitMaskInteraction(args);
},
},
buildDefault() {
@@ -264,119 +267,131 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
},
],
renderer: {
- renderMaskHandlesFeather: true,
- renderMask({ resolvedParams, ctx, width, height, feather }) {
- const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
- const { normalX, normalY, lineX, lineY } = splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
- });
+ body: {
+ kind: "drawWithFeather",
+ drawWithFeather({ resolvedParams, ctx, width, height, feather }) {
+ const { centerX, centerY, rotation } = resolvedParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
- // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
- const featherHalf = feather / 2;
- const gradient = ctx.createLinearGradient(
- lineX - normalX * featherHalf,
- lineY - normalY * featherHalf,
- lineX + normalX * featherHalf,
- lineY + normalY * featherHalf,
- );
- gradient.addColorStop(0, "rgba(255,255,255,0)");
- gradient.addColorStop(1, "white");
+ // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
+ const featherHalf = feather / 2;
+ const gradient = ctx.createLinearGradient(
+ lineX - normalX * featherHalf,
+ lineY - normalY * featherHalf,
+ lineX + normalX * featherHalf,
+ lineY + normalY * featherHalf,
+ );
+ gradient.addColorStop(0, "rgba(255,255,255,0)");
+ gradient.addColorStop(1, "white");
- ctx.fillStyle = gradient;
- ctx.fillRect(0, 0, width, height);
- },
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, width, height);
+ },
- buildPath({ resolvedParams, width, height }) {
- const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
- const { normalX, normalY, lineX, lineY } = splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
- });
+ opaqueFastPath: {
+ buildPath({ resolvedParams, width, height }) {
+ const { centerX, centerY, rotation } = resolvedParams;
+ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
+ centerX,
+ centerY,
+ rotation,
+ width,
+ height,
+ });
- const edges: [number, number, number, number][] = [
- [0, 0, width, 0],
- [width, 0, width, height],
- [width, height, 0, height],
- [0, height, 0, 0],
- ];
+ const edges: [number, number, number, number][] = [
+ [0, 0, width, 0],
+ [width, 0, width, height],
+ [width, height, 0, height],
+ [0, height, 0, 0],
+ ];
- const isInsideHalfPlane = (x: number, y: number) =>
- halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
+ const isInsideHalfPlane = ({
+ x,
+ y,
+ }: {
+ x: number;
+ y: number;
+ }) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
- const vertices: [number, number][] = [];
- for (const [x1, y1, x2, y2] of edges) {
- const isVertex1Inside = isInsideHalfPlane(x1, y1);
- const isVertex2Inside = isInsideHalfPlane(x2, y2);
+ const vertices: [number, number][] = [];
+ for (const [x1, y1, x2, y2] of edges) {
+ const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 });
+ const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 });
- if (isVertex1Inside && isVertex2Inside) {
- vertices.push([x2, y2]);
- } else if (isVertex1Inside && !isVertex2Inside) {
- const hit = lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
- });
- if (hit) vertices.push([hit.x, hit.y]);
- } else if (!isVertex1Inside && isVertex2Inside) {
- const hit = lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
- });
- if (hit) {
- vertices.push([hit.x, hit.y]);
- vertices.push([x2, y2]);
+ if (isVertex1Inside && isVertex2Inside) {
+ vertices.push([x2, y2]);
+ } else if (isVertex1Inside && !isVertex2Inside) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+ if (hit) vertices.push([hit.x, hit.y]);
+ } else if (!isVertex1Inside && isVertex2Inside) {
+ const hit = lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+ });
+ if (hit) {
+ vertices.push([hit.x, hit.y]);
+ vertices.push([x2, y2]);
+ }
+ }
}
- }
- }
- if (
- vertices.length < 3 ||
- polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
- ) {
- return new Path2D();
- }
+ if (
+ vertices.length < 3 ||
+ polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
+ ) {
+ return new Path2D();
+ }
- const path = new Path2D();
- path.moveTo(vertices[0][0], vertices[0][1]);
- for (let i = 1; i < vertices.length; i++) {
- path.lineTo(vertices[i][0], vertices[i][1]);
- }
- path.closePath();
- return path;
+ const path = new Path2D();
+ path.moveTo(vertices[0][0], vertices[0][1]);
+ for (let i = 1; i < vertices.length; i++) {
+ path.lineTo(vertices[i][0], vertices[i][1]);
+ }
+ path.closePath();
+ return path;
+ },
+ },
},
- buildStrokePath({ resolvedParams, width, height }) {
- const segment = getSplitMaskStrokeSegment({
- resolvedParams,
- width,
- height,
- });
- const path = new Path2D();
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const segment = getSplitMaskStrokeSegment({
+ resolvedParams,
+ width,
+ height,
+ });
+ const path = new Path2D();
- if (!segment) {
- return path;
- }
+ if (!segment) {
+ return path;
+ }
- path.moveTo(segment[0].x, segment[0].y);
- path.lineTo(segment[1].x, segment[1].y);
- return path;
+ path.moveTo(segment[0].x, segment[0].y);
+ path.lineTo(segment[1].x, segment[1].y);
+ return path;
+ },
},
},
};
diff --git a/apps/web/src/masks/definitions/star.ts b/apps/web/src/masks/builtin/definitions/star.ts
index a7f8819c..62e9cbc4 100644
--- a/apps/web/src/masks/definitions/star.ts
+++ b/apps/web/src/masks/builtin/definitions/star.ts
@@ -1,4 +1,4 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
+import type { MaskDefinition } from "@/masks/types";
import {
BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction,
@@ -7,7 +7,7 @@ import {
getDefaultSquareMaskParams,
getStrokeOffset,
rotatePoint,
-} from "./box-like";
+} from "../box-like";
const STAR_INNER_RADIUS_RATIO = 0.45;
const STAR_VERTEX_COUNT = 10;
@@ -85,7 +85,7 @@ function buildOverlayStarPath({
return `${segments.join(" ")} Z`;
}
-export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
+export const starMaskDefinition: MaskDefinition<"star"> = {
type: "star",
name: "Star",
features: {
@@ -108,33 +108,39 @@ export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
},
computeParamUpdate: computeBoxMaskParamUpdate,
renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- return buildStarPath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ return buildStarPath({
+ centerX,
+ centerY,
+ halfWidth: maskWidth / 2,
+ halfHeight: maskHeight / 2,
+ rotationRad,
+ });
+ },
},
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- return buildStarPath({
- centerX,
- centerY,
- halfWidth: Math.max(maskWidth / 2 + offset, 1),
- halfHeight: Math.max(maskHeight / 2 + offset, 1),
- rotationRad,
- });
+ stroke: {
+ kind: "strokeFromPath",
+ buildStrokePath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
+ getBoxLikeGeometry({ params, width, height });
+ const offset = getStrokeOffset({
+ strokeAlign: params.strokeAlign,
+ strokeWidth: params.strokeWidth,
+ });
+ return buildStarPath({
+ centerX,
+ centerY,
+ halfWidth: Math.max(maskWidth / 2 + offset, 1),
+ halfHeight: Math.max(maskHeight / 2 + offset, 1),
+ rotationRad,
+ });
+ },
},
},
};
diff --git a/apps/web/src/masks/definitions/text.ts b/apps/web/src/masks/builtin/definitions/text.ts
index c285f1c8..db2af7e0 100644
--- a/apps/web/src/masks/definitions/text.ts
+++ b/apps/web/src/masks/builtin/definitions/text.ts
@@ -4,6 +4,7 @@ import type {
MaskParamUpdateArgs,
TextMask,
TextMaskParams,
+ MaskHandleId,
} from "@/masks/types";
import { DEFAULTS } from "@/timeline/defaults";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography";
@@ -35,7 +36,7 @@ const PERCENTAGE_DISPLAY = {
step: 1,
} as const;
-const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign;
+const TEXT_MASK_ALIGNMENT = "center";
const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [
{
@@ -60,7 +61,7 @@ const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [
key: "fontSize",
label: "Size",
type: "number",
- default: DEFAULTS.text.element.fontSize,
+ default: 15,
min: MIN_FONT_SIZE,
max: MAX_FONT_SIZE,
step: 1,
@@ -124,9 +125,9 @@ function measureTextMask({
function getScalePreferredEdges({
handleId,
}: {
- handleId: string;
+ handleId: MaskHandleId;
}): ScaleEdgePreference | undefined {
- if (handleId !== "scale") {
+ if (handleId.kind !== "scale") {
return undefined;
}
@@ -145,7 +146,7 @@ function computeTextMaskParamUpdate({
startCanvasY,
bounds,
}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> {
- if (handleId === "position") {
+ if (handleId.kind === "position") {
return {
centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height,
@@ -155,7 +156,7 @@ function computeTextMaskParamUpdate({
const pivotX = bounds.cx + startParams.centerX * bounds.width;
const pivotY = bounds.cy + startParams.centerY * bounds.height;
- if (handleId === "rotation") {
+ if (handleId.kind === "rotation") {
const startAngle =
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
Math.PI;
@@ -176,7 +177,7 @@ function computeTextMaskParamUpdate({
};
}
- if (handleId === "feather") {
+ if (handleId.kind === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({
startFeather: startParams.feather,
@@ -187,7 +188,7 @@ function computeTextMaskParamUpdate({
});
}
- if (handleId === "scale") {
+ if (handleId.kind === "scale") {
const startDistance = Math.hypot(
startCanvasX - pivotX,
startCanvasY - pivotY,
@@ -205,7 +206,7 @@ function computeTextMaskParamUpdate({
return {};
}
-export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
+export const textMaskDefinition: MaskDefinition<"text"> = {
type: "text",
name: "Text",
features: {
@@ -269,7 +270,7 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
y: proposedParams.centerY * bounds.height,
};
- if (handleId === "position") {
+ if (handleId.kind === "position") {
const { snappedPosition, activeLines } = snapPosition({
proposedPosition: position,
canvasSize: bounds,
@@ -297,7 +298,7 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
};
}
- if (handleId === "rotation") {
+ if (handleId.kind === "rotation") {
const { snappedRotation } = snapRotation({
proposedRotation: proposedParams.rotation,
});
@@ -310,7 +311,7 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
};
}
- if (handleId === "scale") {
+ if (handleId.kind === "scale") {
const { snappedScale, activeLines } = snapScale({
proposedScale: proposedParams.scale,
position,
@@ -351,11 +352,11 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
strokeWidth: 0,
strokeAlign: "center",
content: "Mask",
- fontSize: DEFAULTS.text.element.fontSize,
- fontFamily: DEFAULTS.text.element.fontFamily,
- fontWeight: DEFAULTS.text.element.fontWeight,
- fontStyle: DEFAULTS.text.element.fontStyle,
- textDecoration: DEFAULTS.text.element.textDecoration,
+ fontSize: 15,
+ fontFamily: "Arial",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
letterSpacing: DEFAULTS.text.letterSpacing,
lineHeight: DEFAULTS.text.lineHeight,
centerX: 0,
@@ -370,69 +371,75 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
return params.content.trim().length > 0;
},
renderer: {
- renderMask({ resolvedParams, ctx, width, height }) {
- const params = resolvedParams as TextMaskParams;
- const { layout } = measureTextMask({ params, height });
+ body: {
+ kind: "drawOpaque",
+ drawOpaque({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams;
+ const { layout } = measureTextMask({ params, height });
- ctx.save();
- ctx.translate(
- width / 2 + params.centerX * width,
- height / 2 + params.centerY * height,
- );
- ctx.scale(params.scale, params.scale);
- if (params.rotation) {
- ctx.rotate((params.rotation * Math.PI) / 180);
- }
- drawMeasuredTextLayout({
- ctx,
- layout,
- textColor: "#ffffff",
- background: null,
- });
- ctx.restore();
- },
- renderStroke({ resolvedParams, ctx, width, height }) {
- const params = resolvedParams as TextMaskParams;
- const { layout } = measureTextMask({ params, height });
-
- ctx.save();
- ctx.translate(
- width / 2 + params.centerX * width,
- height / 2 + params.centerY * height,
- );
- ctx.scale(params.scale, params.scale);
- if (params.rotation) {
- ctx.rotate((params.rotation * Math.PI) / 180);
- }
-
- strokeMeasuredTextLayout({
- ctx,
- layout,
- strokeColor: params.strokeColor,
- strokeWidth: params.strokeWidth,
- });
-
- if (params.strokeAlign === "inside") {
- ctx.globalCompositeOperation = "destination-in";
+ ctx.save();
+ ctx.translate(
+ width / 2 + params.centerX * width,
+ height / 2 + params.centerY * height,
+ );
+ ctx.scale(params.scale, params.scale);
+ if (params.rotation) {
+ ctx.rotate((params.rotation * Math.PI) / 180);
+ }
drawMeasuredTextLayout({
ctx,
layout,
textColor: "#ffffff",
background: null,
});
- }
+ ctx.restore();
+ },
+ },
+ stroke: {
+ kind: "renderStroke",
+ renderStroke({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams;
+ const { layout } = measureTextMask({ params, height });
- if (params.strokeAlign === "outside") {
- ctx.globalCompositeOperation = "destination-out";
- drawMeasuredTextLayout({
+ ctx.save();
+ ctx.translate(
+ width / 2 + params.centerX * width,
+ height / 2 + params.centerY * height,
+ );
+ ctx.scale(params.scale, params.scale);
+ if (params.rotation) {
+ ctx.rotate((params.rotation * Math.PI) / 180);
+ }
+
+ strokeMeasuredTextLayout({
ctx,
layout,
- textColor: "#ffffff",
- background: null,
+ strokeColor: params.strokeColor,
+ strokeWidth: params.strokeWidth,
});
- }
- ctx.restore();
+ if (params.strokeAlign === "inside") {
+ ctx.globalCompositeOperation = "destination-in";
+ drawMeasuredTextLayout({
+ ctx,
+ layout,
+ textColor: "#ffffff",
+ background: null,
+ });
+ }
+
+ if (params.strokeAlign === "outside") {
+ ctx.globalCompositeOperation = "destination-out";
+ drawMeasuredTextLayout({
+ ctx,
+ layout,
+ textColor: "#ffffff",
+ background: null,
+ });
+ }
+
+ ctx.restore();
+ },
},
},
};
diff --git a/apps/web/src/masks/components/masks-tab.tsx b/apps/web/src/masks/components/masks-tab.tsx
index 97fba3ab..d6729d6d 100644
--- a/apps/web/src/masks/components/masks-tab.tsx
+++ b/apps/web/src/masks/components/masks-tab.tsx
@@ -3,7 +3,11 @@
import type { MaskableElement } from "@/timeline";
import type { Mask, MaskType, TextMask } from "@/masks/types";
import type { NumberParamDefinition, SelectParamDefinition } from "@/params";
-import { masksRegistry, buildDefaultMaskInstance } from "@/masks";
+import {
+ buildDefaultMaskInstance,
+ getMaskDefinition,
+ getMaskDefinitionsForMenu,
+} from "@/masks";
import { useEditor } from "@/editor/use-editor";
import { useElementPreview } from "@/timeline/hooks/use-element-preview";
import { useMenuPreview } from "@/editor/use-menu-preview";
@@ -85,12 +89,43 @@ type PreviewParamHandler = (
key: string,
) => (value: number | string | boolean) => void;
-type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>;
+type RegisteredMaskDefinition = ReturnType<typeof getMaskDefinition>;
function isTextMask(mask: Mask): mask is TextMask {
return mask.type === "text";
}
+function withPreviewedMaskParam({
+ mask,
+ key,
+ value,
+}: {
+ mask: Mask;
+ key: string;
+ value: number | string | boolean;
+}): Mask {
+ switch (mask.type) {
+ case "split":
+ return { ...mask, params: { ...mask.params, [key]: value } };
+ case "cinematic-bars":
+ return { ...mask, params: { ...mask.params, [key]: value } };
+ case "rectangle":
+ return { ...mask, params: { ...mask.params, [key]: value } };
+ case "ellipse":
+ return { ...mask, params: { ...mask.params, [key]: value } };
+ case "heart":
+ return { ...mask, params: { ...mask.params, [key]: value } };
+ case "diamond":
+ return { ...mask, params: { ...mask.params, [key]: value } };
+ case "star":
+ return { ...mask, params: { ...mask.params, [key]: value } };
+ case "text":
+ return { ...mask, params: { ...mask.params, [key]: value } };
+ case "freeform":
+ return { ...mask, params: { ...mask.params, [key]: value } };
+ }
+}
+
export function MasksTab({ element, trackId }: MasksTabProps) {
const editor = useEditor();
const { renderElement, previewUpdates, commit } =
@@ -99,7 +134,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
elementId: element.id,
fallback: element,
});
- const maskDefs = masksRegistry.getAll();
+ const maskDefs = getMaskDefinitionsForMenu();
const tracks = useEditor(
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
);
@@ -210,16 +245,10 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
const updatedMasks = renderMasks.map((existingMask, maskIndex) =>
maskIndex !== index
? existingMask
- : {
- ...existingMask,
- params: {
- ...existingMask.params,
- [key]: value,
- },
- },
+ : withPreviewedMaskParam({ mask: existingMask, key, value }),
);
- previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>);
+ previewUpdates({ masks: updatedMasks });
};
return (
@@ -301,7 +330,7 @@ function MaskItem({
onCommit,
}: MaskItemProps) {
const editor = useEditor();
- const definition = masksRegistry.get(mask.type);
+ const definition = getMaskDefinition(mask.type);
return (
<Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}>
diff --git a/apps/web/src/masks/definitions/ellipse.ts b/apps/web/src/masks/definitions/ellipse.ts
deleted file mode 100644
index d0567817..00000000
--- a/apps/web/src/masks/definitions/ellipse.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
-} from "./box-like";
-
-export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "ellipse",
- name: "Ellipse",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- buildOverlayPath({ width, height }) {
- const rx = Math.max((width - 1) / 2, 0);
- const ry = Math.max((height - 1) / 2, 0);
- const cx = width / 2;
- const cy = height / 2;
- return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
- },
- }),
- buildDefault(context) {
- return {
- type: "ellipse",
- params: getDefaultSquareMaskParams(context),
- };
- },
- computeParamUpdate: computeBoxMaskParamUpdate,
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const path = new Path2D();
- path.ellipse(
- centerX,
- centerY,
- maskWidth / 2,
- maskHeight / 2,
- rotationRad,
- 0,
- Math.PI * 2,
- );
- return path;
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- const path = new Path2D();
- path.ellipse(
- centerX,
- centerY,
- Math.max(1, maskWidth / 2 + offset),
- Math.max(1, maskHeight / 2 + offset),
- rotationRad,
- 0,
- Math.PI * 2,
- );
- return path;
- },
- },
-};
diff --git a/apps/web/src/masks/definitions/custom.ts b/apps/web/src/masks/freeform/definition.ts
index cd6e2055..ae30110e 100644
--- a/apps/web/src/masks/definitions/custom.ts
+++ b/apps/web/src/masks/freeform/definition.ts
@@ -3,27 +3,26 @@ import type { ParamDefinition } from "@/params";
import { PEN_CURSOR } from "@/preview/components/cursors";
import type { ElementBounds } from "@/preview/element-bounds";
import type {
- CustomMask,
- CustomMaskParams,
+ FreeformPathMask,
+ FreeformPathMaskParams,
MaskDefinition,
MaskHandlePosition,
MaskOverlay,
MaskParamUpdateArgs,
} from "@/masks/types";
import {
- buildCustomMaskPath2D,
- buildCustomMaskSvgPath,
- customMaskCanvasPointToLocal,
- findClosestPointOnCustomMaskSegment,
- getCustomMaskCanvasSegments,
- getCustomMaskCanvasGeometry,
- getCustomMaskLocalBounds,
- getCustomMaskSegmentCount,
- insertPointIntoCustomMaskSegment,
- parseCustomMaskHandleId,
- recenterCustomMaskPath,
- type CustomMaskPathPoint,
-} from "@/masks/custom-path";
+ buildFreeformPath2D,
+ buildFreeformSvgPath,
+ freeformCanvasPointToLocal,
+ findClosestPointOnFreeformSegment,
+ getFreeformCanvasSegments,
+ getFreeformCanvasGeometry,
+ getFreeformLocalBounds,
+ getFreeformSegmentCount,
+ insertPointIntoFreeformSegment,
+ recenterFreeformPath,
+ type FreeformPathPoint,
+} from "@/masks/freeform/path";
import { getBoxMaskHandlePositions } from "@/masks/handle-positions";
import { computeFeatherUpdate } from "@/masks/param-update";
import {
@@ -41,7 +40,7 @@ const PERCENTAGE_DISPLAY = {
step: 1,
} as const;
-const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [
+const FREEFORM_PATH_MASK_PARAMS: ParamDefinition<keyof FreeformPathMaskParams & string>[] = [
{
key: "centerX",
label: "X",
@@ -80,12 +79,12 @@ const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [
},
];
-function getCustomMaskDisplayHandles({
+function getFreeformDisplayHandles({
params,
displayScale,
bounds,
}: {
- params: CustomMaskParams;
+ params: FreeformPathMaskParams;
displayScale: number;
bounds: ElementBounds;
}): {
@@ -93,7 +92,7 @@ function getCustomMaskDisplayHandles({
overlays: MaskOverlay[];
} {
const points = params.path;
- const geometry = getCustomMaskCanvasGeometry({
+ const geometry = getFreeformCanvasGeometry({
points,
centerX: params.centerX,
centerY: params.centerY,
@@ -109,7 +108,7 @@ function getCustomMaskDisplayHandles({
overlays.push({
id: "path",
type: "canvas-path",
- pathData: buildCustomMaskSvgPath({
+ pathData: buildFreeformSvgPath({
points,
centerX: params.centerX,
centerY: params.centerY,
@@ -125,7 +124,7 @@ function getCustomMaskDisplayHandles({
if (params.closed) {
const segmentStrokeWidth = 12;
overlays.push(
- ...getCustomMaskCanvasSegments({
+ ...getFreeformCanvasSegments({
points,
centerX: params.centerX,
centerY: params.centerY,
@@ -133,12 +132,12 @@ function getCustomMaskDisplayHandles({
scale: params.scale,
bounds,
closed: true,
- }).map((segment) => ({
+ }).map((segment): MaskOverlay => ({
id: `segment:${segment.index}`,
type: "canvas-path" as const,
pathData: segment.pathData,
coordinateSpace: "canvas" as const,
- handleId: `segment:${segment.index}`,
+ handleId: { kind: "segment", index: segment.index },
cursor: PEN_CURSOR,
strokeOpacity: 0,
strokeWidth: segmentStrokeWidth,
@@ -146,7 +145,7 @@ function getCustomMaskDisplayHandles({
);
}
- const localBounds = getCustomMaskLocalBounds({ points, bounds });
+ const localBounds = getFreeformLocalBounds({ points, bounds });
if (params.closed && localBounds) {
handles.push(
...getBoxMaskHandlePositions({
@@ -166,7 +165,7 @@ function getCustomMaskDisplayHandles({
geometry.anchors.forEach((point) => {
handles.push({
- id: `point:${point.id}:anchor`,
+ id: { kind: "anchor", pointId: point.id },
x: point.anchor.x,
y: point.anchor.y,
cursor: params.closed ? "move" : "pointer",
@@ -180,19 +179,19 @@ function getCustomMaskDisplayHandles({
};
}
-function updateCustomMaskPoint({
+function updateFreeformPathMaskPoint({
points,
pointId,
updater,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
pointId: string;
- updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint;
+ updater: (point: FreeformPathPoint) => FreeformPathPoint;
}) {
return points.map((point) => (point.id === pointId ? updater(point) : point));
}
-function computeCustomMaskParamUpdate({
+function computeFreeformParamUpdate({
handleId,
startParams,
deltaX,
@@ -200,8 +199,8 @@ function computeCustomMaskParamUpdate({
startCanvasX,
startCanvasY,
bounds,
-}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> {
- if (handleId === "position") {
+}: MaskParamUpdateArgs<FreeformPathMaskParams>): Partial<FreeformPathMaskParams> {
+ if (handleId.kind === "position") {
return {
centerX: startParams.centerX + deltaX / bounds.width,
centerY: startParams.centerY + deltaY / bounds.height,
@@ -211,7 +210,7 @@ function computeCustomMaskParamUpdate({
const pivotX = bounds.cx + startParams.centerX * bounds.width;
const pivotY = bounds.cy + startParams.centerY * bounds.height;
- if (handleId === "rotation") {
+ if (handleId.kind === "rotation") {
const startAngle =
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
Math.PI;
@@ -232,7 +231,7 @@ function computeCustomMaskParamUpdate({
};
}
- if (handleId === "feather") {
+ if (handleId.kind === "feather") {
const angleRad = (startParams.rotation * Math.PI) / 180;
return computeFeatherUpdate({
startFeather: startParams.feather,
@@ -243,7 +242,7 @@ function computeCustomMaskParamUpdate({
});
}
- if (handleId === "scale") {
+ if (handleId.kind === "scale") {
const startDistance = Math.hypot(
startCanvasX - pivotX,
startCanvasY - pivotY,
@@ -258,8 +257,7 @@ function computeCustomMaskParamUpdate({
};
}
- const parsedHandle = parseCustomMaskHandleId({ handleId });
- if (!parsedHandle) {
+ if (handleId.kind !== "anchor") {
return {};
}
@@ -268,7 +266,7 @@ function computeCustomMaskParamUpdate({
x: startCanvasX + deltaX,
y: startCanvasY + deltaY,
};
- const localPoint = customMaskCanvasPointToLocal({
+ const localPoint = freeformCanvasPointToLocal({
point: currentPoint,
centerX: startParams.centerX,
centerY: startParams.centerY,
@@ -278,45 +276,27 @@ function computeCustomMaskParamUpdate({
});
return {
- path: updateCustomMaskPoint({
+ path: updateFreeformPathMaskPoint({
points,
- pointId: parsedHandle.pointId,
- updater: (point) => {
- if (parsedHandle.part === "anchor") {
- return {
- ...point,
- x: localPoint.x,
- y: localPoint.y,
- };
- }
-
- if (parsedHandle.part === "in") {
- return {
- ...point,
- inX: localPoint.x - point.x,
- inY: localPoint.y - point.y,
- };
- }
-
- return {
- ...point,
- outX: localPoint.x - point.x,
- outY: localPoint.y - point.y,
- };
- },
+ pointId: handleId.pointId,
+ updater: (point) => ({
+ ...point,
+ x: localPoint.x,
+ y: localPoint.y,
+ }),
}),
};
}
-export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
- type: "custom",
- name: "Custom",
+export const freeformMaskDefinition: MaskDefinition<"freeform"> = {
+ type: "freeform",
+ name: "Pen tool",
features: {
hasPosition: true,
hasRotation: true,
sizeMode: "uniform",
},
- params: CUSTOM_MASK_PARAMS,
+ params: FREEFORM_PATH_MASK_PARAMS,
interaction: {
getInteraction({
params,
@@ -325,7 +305,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
scaleX: _scaleX,
scaleY: _scaleY,
}) {
- return getCustomMaskDisplayHandles({ params, bounds, displayScale });
+ return getFreeformDisplayHandles({ params, bounds, displayScale });
},
snap({
handleId,
@@ -336,7 +316,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
snapThreshold,
}) {
const points = startParams.path;
- const localBounds = getCustomMaskLocalBounds({ points, bounds });
+ const localBounds = getFreeformLocalBounds({ points, bounds });
if (!startParams.closed || !localBounds) {
return {
params: proposedParams,
@@ -349,7 +329,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
y: proposedParams.centerY * bounds.height,
};
- if (handleId === "position") {
+ if (handleId.kind === "position") {
const { snappedPosition, activeLines } = snapPosition({
proposedPosition: position,
canvasSize: bounds,
@@ -377,7 +357,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
};
}
- if (handleId === "rotation") {
+ if (handleId.kind === "rotation") {
const { snappedRotation } = snapRotation({
proposedRotation: proposedParams.rotation,
});
@@ -390,7 +370,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
};
}
- if (handleId === "scale") {
+ if (handleId.kind === "scale") {
const { snappedScale, activeLines } = snapScale({
proposedScale: proposedParams.scale,
position,
@@ -424,9 +404,9 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
};
},
},
- buildDefault(): Omit<CustomMask, "id"> {
+ buildDefault(): Omit<FreeformPathMask, "id"> {
return {
- type: "custom",
+ type: "freeform",
params: {
feather: 0,
inverted: false,
@@ -442,89 +422,95 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
},
};
},
- computeParamUpdate: computeCustomMaskParamUpdate,
+ computeParamUpdate: computeFreeformParamUpdate,
isActive(params) {
return params.closed;
},
renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as CustomMaskParams;
- const points = params.path;
- if (!params.closed) {
- return new Path2D();
- }
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const points = params.path;
+ if (!params.closed) {
+ return new Path2D();
+ }
- return buildCustomMaskPath2D({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds: {
- cx: width / 2,
- cy: height / 2,
- width,
- height,
- rotation: 0,
- },
- closed: true,
- });
+ return buildFreeformPath2D({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds: {
+ cx: width / 2,
+ cy: height / 2,
+ width,
+ height,
+ rotation: 0,
+ },
+ closed: true,
+ });
+ },
},
- renderStroke({ resolvedParams, ctx, width, height }) {
- const params = resolvedParams as CustomMaskParams;
- if (!params.closed) {
- return;
- }
+ stroke: {
+ kind: "renderStroke",
+ renderStroke({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams;
+ if (!params.closed) {
+ return;
+ }
- const points = params.path;
- const path = buildCustomMaskPath2D({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds: {
- cx: width / 2,
- cy: height / 2,
- width,
- height,
- rotation: 0,
- },
- closed: true,
- });
+ const points = params.path;
+ const path = buildFreeformPath2D({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds: {
+ cx: width / 2,
+ cy: height / 2,
+ width,
+ height,
+ rotation: 0,
+ },
+ closed: true,
+ });
- ctx.save();
- ctx.strokeStyle = params.strokeColor;
- ctx.lineWidth = params.strokeWidth;
- ctx.lineJoin = "round";
- ctx.lineCap = "round";
- ctx.stroke(path);
+ ctx.save();
+ ctx.strokeStyle = params.strokeColor;
+ ctx.lineWidth = params.strokeWidth;
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ ctx.stroke(path);
- if (params.strokeAlign === "inside") {
- ctx.globalCompositeOperation = "destination-in";
- ctx.fillStyle = "#ffffff";
- ctx.fill(path);
- }
+ if (params.strokeAlign === "inside") {
+ ctx.globalCompositeOperation = "destination-in";
+ ctx.fillStyle = "#ffffff";
+ ctx.fill(path);
+ }
- if (params.strokeAlign === "outside") {
- ctx.globalCompositeOperation = "destination-out";
- ctx.fillStyle = "#ffffff";
- ctx.fill(path);
- }
- ctx.restore();
+ if (params.strokeAlign === "outside") {
+ ctx.globalCompositeOperation = "destination-out";
+ ctx.fillStyle = "#ffffff";
+ ctx.fill(path);
+ }
+ ctx.restore();
+ },
},
},
};
-export function appendPointToCustomMask({
+export function appendPointToFreeformPathMask({
params,
canvasPoint,
bounds,
}: {
- params: CustomMaskParams;
+ params: FreeformPathMaskParams;
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
-}): CustomMaskParams {
+}): FreeformPathMaskParams {
const points = params.path;
if (points.length === 0) {
@@ -550,7 +536,7 @@ export function appendPointToCustomMask({
};
}
- const localPoint = customMaskCanvasPointToLocal({
+ const localPoint = freeformCanvasPointToLocal({
point: canvasPoint,
centerX: params.centerX,
centerY: params.centerY,
@@ -570,7 +556,7 @@ export function appendPointToCustomMask({
outY: 0,
},
];
- const recentered = recenterCustomMaskPath({
+ const recentered = recenterFreeformPath({
points: nextPoints,
centerX: params.centerX,
centerY: params.centerY,
@@ -587,25 +573,25 @@ export function appendPointToCustomMask({
};
}
-export function insertPointOnCustomMaskSegment({
+export function insertPointOnFreeformSegment({
params,
segmentIndex,
canvasPoint,
bounds,
pointId = generateUUID(),
}: {
- params: CustomMaskParams;
+ params: FreeformPathMaskParams;
segmentIndex: number;
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
pointId?: string;
-}): { params: CustomMaskParams; pointId: string } | null {
+}): { params: FreeformPathMaskParams; pointId: string } | null {
const points = params.path;
- if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) {
+ if (getFreeformSegmentCount({ points, closed: params.closed }) === 0) {
return null;
}
- const closestPoint = findClosestPointOnCustomMaskSegment({
+ const closestPoint = findClosestPointOnFreeformSegment({
points,
segmentIndex,
canvasPoint,
@@ -620,7 +606,7 @@ export function insertPointOnCustomMaskSegment({
return null;
}
- const nextPoints = insertPointIntoCustomMaskSegment({
+ const nextPoints = insertPointIntoFreeformSegment({
points,
segmentIndex,
pointId,
@@ -631,7 +617,7 @@ export function insertPointOnCustomMaskSegment({
return null;
}
- const recentered = recenterCustomMaskPath({
+ const recentered = recenterFreeformPath({
points: nextPoints,
centerX: params.centerX,
centerY: params.centerY,
diff --git a/apps/web/src/masks/custom-path.ts b/apps/web/src/masks/freeform/path.ts
index 0349df63..fa6217c2 100644
--- a/apps/web/src/masks/custom-path.ts
+++ b/apps/web/src/masks/freeform/path.ts
@@ -1,6 +1,6 @@
import type { ElementBounds } from "@/preview/element-bounds";
-export interface CustomMaskPathPoint {
+export interface FreeformPathPoint {
id: string;
x: number;
y: number;
@@ -10,88 +10,61 @@ export interface CustomMaskPathPoint {
outY: number;
}
-export type CustomMaskHandlePart = "anchor" | "in" | "out";
-
-function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
+function isFreeformPathPoint(value: unknown): value is FreeformPathPoint {
if (!value || typeof value !== "object") {
return false;
}
- const candidate = value as Record<string, unknown>;
return (
- typeof candidate.id === "string" &&
- typeof candidate.x === "number" &&
- typeof candidate.y === "number" &&
- typeof candidate.inX === "number" &&
- typeof candidate.inY === "number" &&
- typeof candidate.outX === "number" &&
- typeof candidate.outY === "number"
+ "id" in value &&
+ typeof value.id === "string" &&
+ "x" in value &&
+ typeof value.x === "number" &&
+ "y" in value &&
+ typeof value.y === "number" &&
+ "inX" in value &&
+ typeof value.inX === "number" &&
+ "inY" in value &&
+ typeof value.inY === "number" &&
+ "outX" in value &&
+ typeof value.outX === "number" &&
+ "outY" in value &&
+ typeof value.outY === "number"
);
}
-export function parseCustomMaskHandleId({
- handleId,
-}: {
- handleId: string;
-}): { pointId: string; part: CustomMaskHandlePart } | null {
- const match = /^point:(.+):(anchor|in|out)$/.exec(handleId);
- if (!match) {
- return null;
- }
-
- return {
- pointId: match[1],
- part: match[2] as CustomMaskHandlePart,
- };
-}
-
-export function parseCustomMaskSegmentHandleId({
- handleId,
-}: {
- handleId: string;
-}): { segmentIndex: number } | null {
- const match = /^segment:(\d+)$/.exec(handleId);
- if (!match) {
- return null;
- }
-
- return {
- segmentIndex: Number.parseInt(match[1], 10),
- };
-}
-
-export function parseCustomMaskPath({
+export function parseFreeformPath({
path,
}: {
path: string;
-}): CustomMaskPathPoint[] {
+}): FreeformPathPoint[] {
if (!path) {
return [];
}
try {
const parsed = JSON.parse(path);
- return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : [];
+ return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : [];
} catch {
return [];
}
}
-export function serializeCustomMaskPath({
+export function serializeFreeformPath({
points,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
}): string {
return JSON.stringify(points);
}
-export function removeCustomMaskPoints({
+export function removeFreeformPathPoints({
points,
pointIds,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
pointIds: string[];
-}): CustomMaskPathPoint[] {
+}): FreeformPathPoint[] {
if (pointIds.length === 0) {
return points;
}
@@ -99,7 +72,7 @@ export function removeCustomMaskPoints({
return points.filter((point) => !pointIdsToRemove.has(point.id));
}
-export function getCustomMaskClosedStateAfterPointRemoval({
+export function getFreeformPathClosedStateAfterPointRemoval({
wasClosed,
remainingPointCount,
}: {
@@ -127,7 +100,7 @@ function rotatePoint({
};
}
-export function getCustomMaskCenterCanvasPoint({
+export function getFreeformCenterCanvasPoint({
centerX,
centerY,
bounds,
@@ -142,7 +115,7 @@ export function getCustomMaskCenterCanvasPoint({
};
}
-export function customMaskLocalPointToCanvas({
+export function freeformLocalPointToCanvas({
point,
centerX,
centerY,
@@ -157,7 +130,7 @@ export function customMaskLocalPointToCanvas({
scale: number;
bounds: ElementBounds;
}): { x: number; y: number } {
- const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds });
+ const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds });
const scaledLocal = {
x: point.x * bounds.width * scale,
y: point.y * bounds.height * scale,
@@ -174,7 +147,7 @@ export function customMaskLocalPointToCanvas({
};
}
-export function customMaskCanvasPointToLocal({
+export function freeformCanvasPointToLocal({
point,
centerX,
centerY,
@@ -189,7 +162,7 @@ export function customMaskCanvasPointToLocal({
scale: number;
bounds: ElementBounds;
}): { x: number; y: number } {
- const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds });
+ const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds });
const translated = {
x: point.x - center.x,
y: point.y - center.y,
@@ -206,7 +179,7 @@ export function customMaskCanvasPointToLocal({
};
}
-export function getCustomMaskCanvasGeometry({
+export function getFreeformCanvasGeometry({
points,
centerX,
centerY,
@@ -214,7 +187,7 @@ export function getCustomMaskCanvasGeometry({
scale,
bounds,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
centerX: number;
centerY: number;
rotation: number;
@@ -223,7 +196,7 @@ export function getCustomMaskCanvasGeometry({
}) {
const anchors = points.map((point) => ({
id: point.id,
- anchor: customMaskLocalPointToCanvas({
+ anchor: freeformLocalPointToCanvas({
point: { x: point.x, y: point.y },
centerX,
centerY,
@@ -231,7 +204,7 @@ export function getCustomMaskCanvasGeometry({
scale,
bounds,
}),
- inHandle: customMaskLocalPointToCanvas({
+ inHandle: freeformLocalPointToCanvas({
point: { x: point.x + point.inX, y: point.y + point.inY },
centerX,
centerY,
@@ -239,7 +212,7 @@ export function getCustomMaskCanvasGeometry({
scale,
bounds,
}),
- outHandle: customMaskLocalPointToCanvas({
+ outHandle: freeformLocalPointToCanvas({
point: { x: point.x + point.outX, y: point.y + point.outY },
centerX,
centerY,
@@ -315,7 +288,7 @@ function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): {
};
}
-export interface CustomMaskCanvasSegment {
+export interface FreeformCanvasSegment {
index: number;
startPointId: string;
endPointId: string;
@@ -330,13 +303,27 @@ function clampUnit(value: number): number {
return Math.min(1, Math.max(0, value));
}
-function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number {
+function getDistanceSquared({
+ a,
+ b,
+}: {
+ a: CanvasPoint;
+ b: CanvasPoint;
+}): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
return dx * dx + dy * dy;
}
-function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint {
+function lerpPoint({
+ a,
+ b,
+ t,
+}: {
+ a: CanvasPoint;
+ b: CanvasPoint;
+ t: number;
+}): CanvasPoint {
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
@@ -371,16 +358,16 @@ function evaluateCubicBezier({
};
}
-function getCustomMaskSegmentIndices({
+function getFreeformSegmentIndices({
points,
segmentIndex,
closed,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
segmentIndex: number;
closed: boolean;
}): { startIndex: number; endIndex: number } | null {
- const segmentCount = getCustomMaskSegmentCount({ points, closed });
+ const segmentCount = getFreeformSegmentCount({ points, closed });
if (segmentIndex < 0 || segmentIndex >= segmentCount) {
return null;
}
@@ -391,11 +378,11 @@ function getCustomMaskSegmentIndices({
};
}
-export function getCustomMaskSegmentCount({
+export function getFreeformSegmentCount({
points,
closed,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
closed: boolean;
}): number {
if (points.length < 2) {
@@ -404,7 +391,7 @@ export function getCustomMaskSegmentCount({
return closed ? points.length : points.length - 1;
}
-export function getCustomMaskCanvasSegments({
+export function getFreeformCanvasSegments({
points,
centerX,
centerY,
@@ -413,15 +400,15 @@ export function getCustomMaskCanvasSegments({
bounds,
closed,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
closed: boolean;
-}): CustomMaskCanvasSegment[] {
- const geometry = getCustomMaskCanvasGeometry({
+}): FreeformCanvasSegment[] {
+ const geometry = getFreeformCanvasGeometry({
points,
centerX,
centerY,
@@ -429,7 +416,7 @@ export function getCustomMaskCanvasSegments({
scale,
bounds,
});
- const segmentCount = getCustomMaskSegmentCount({ points, closed });
+ const segmentCount = getFreeformSegmentCount({ points, closed });
return Array.from({ length: segmentCount }, (_, segmentIndex) => {
const start = geometry.anchors[segmentIndex];
@@ -447,7 +434,7 @@ export function getCustomMaskCanvasSegments({
});
}
-export function findClosestPointOnCustomMaskSegment({
+export function findClosestPointOnFreeformSegment({
points,
segmentIndex,
canvasPoint,
@@ -458,7 +445,7 @@ export function findClosestPointOnCustomMaskSegment({
bounds,
closed,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
segmentIndex: number;
canvasPoint: CanvasPoint;
centerX: number;
@@ -468,7 +455,7 @@ export function findClosestPointOnCustomMaskSegment({
bounds: ElementBounds;
closed: boolean;
}): { t: number; point: CanvasPoint } | null {
- const segment = getCustomMaskCanvasSegments({
+ const segment = getFreeformCanvasSegments({
points,
centerX,
centerY,
@@ -483,7 +470,10 @@ export function findClosestPointOnCustomMaskSegment({
const sampleCount = 24;
let bestT = 0;
- let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start);
+ let bestDistanceSquared = getDistanceSquared({
+ a: canvasPoint,
+ b: segment.start,
+ });
for (let step = 0; step <= sampleCount; step++) {
const t = step / sampleCount;
@@ -494,7 +484,7 @@ export function findClosestPointOnCustomMaskSegment({
p3: segment.end,
t,
});
- const distanceSquared = getDistanceSquared(canvasPoint, point);
+ const distanceSquared = getDistanceSquared({ a: canvasPoint, b: point });
if (distanceSquared < bestDistanceSquared) {
bestDistanceSquared = distanceSquared;
bestT = t;
@@ -516,7 +506,10 @@ export function findClosestPointOnCustomMaskSegment({
}),
}));
for (const candidate of candidates) {
- const distanceSquared = getDistanceSquared(canvasPoint, candidate.point);
+ const distanceSquared = getDistanceSquared({
+ a: canvasPoint,
+ b: candidate.point,
+ });
if (distanceSquared < bestDistanceSquared) {
bestDistanceSquared = distanceSquared;
bestT = candidate.t;
@@ -538,20 +531,20 @@ export function findClosestPointOnCustomMaskSegment({
};
}
-export function insertPointIntoCustomMaskSegment({
+export function insertPointIntoFreeformSegment({
points,
segmentIndex,
pointId,
t,
closed,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
segmentIndex: number;
pointId: string;
t: number;
closed: boolean;
-}): CustomMaskPathPoint[] {
- const indices = getCustomMaskSegmentIndices({
+}): FreeformPathPoint[] {
+ const indices = getFreeformSegmentIndices({
points,
segmentIndex,
closed,
@@ -573,12 +566,12 @@ export function insertPointIntoCustomMaskSegment({
y: endPoint.y + endPoint.inY,
};
const p3 = { x: endPoint.x, y: endPoint.y };
- const p01 = lerpPoint(p0, p1, clampedT);
- const p12 = lerpPoint(p1, p2, clampedT);
- const p23 = lerpPoint(p2, p3, clampedT);
- const p012 = lerpPoint(p01, p12, clampedT);
- const p123 = lerpPoint(p12, p23, clampedT);
- const splitPoint = lerpPoint(p012, p123, clampedT);
+ const p01 = lerpPoint({ a: p0, b: p1, t: clampedT });
+ const p12 = lerpPoint({ a: p1, b: p2, t: clampedT });
+ const p23 = lerpPoint({ a: p2, b: p3, t: clampedT });
+ const p012 = lerpPoint({ a: p01, b: p12, t: clampedT });
+ const p123 = lerpPoint({ a: p12, b: p23, t: clampedT });
+ const splitPoint = lerpPoint({ a: p012, b: p123, t: clampedT });
const nextPoints = [...points];
nextPoints[indices.startIndex] = {
@@ -603,11 +596,11 @@ export function insertPointIntoCustomMaskSegment({
return nextPoints;
}
-export function getCustomMaskLocalBounds({
+export function getFreeformLocalBounds({
points,
bounds,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
bounds: ElementBounds;
}) {
if (points.length === 0) {
@@ -639,7 +632,7 @@ export function getCustomMaskLocalBounds({
};
}
-export function recenterCustomMaskPath({
+export function recenterFreeformPath({
points,
centerX,
centerY,
@@ -647,7 +640,7 @@ export function recenterCustomMaskPath({
scale,
bounds,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
centerX: number;
centerY: number;
rotation: number;
@@ -658,7 +651,7 @@ export function recenterCustomMaskPath({
return { centerX, centerY, points };
}
- const geometry = getCustomMaskCanvasGeometry({
+ const geometry = getFreeformCanvasGeometry({
points,
centerX,
centerY,
@@ -683,7 +676,7 @@ export function recenterCustomMaskPath({
};
const nextPoints = geometry.anchors.map((point) => {
- const anchor = customMaskCanvasPointToLocal({
+ const anchor = freeformCanvasPointToLocal({
point: point.anchor,
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
@@ -691,7 +684,7 @@ export function recenterCustomMaskPath({
scale,
bounds,
});
- const inHandle = customMaskCanvasPointToLocal({
+ const inHandle = freeformCanvasPointToLocal({
point: point.inHandle,
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
@@ -699,7 +692,7 @@ export function recenterCustomMaskPath({
scale,
bounds,
});
- const outHandle = customMaskCanvasPointToLocal({
+ const outHandle = freeformCanvasPointToLocal({
point: point.outHandle,
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
@@ -726,7 +719,7 @@ export function recenterCustomMaskPath({
};
}
-export function buildCustomMaskPath2D({
+export function buildFreeformPath2D({
points,
centerX,
centerY,
@@ -735,7 +728,7 @@ export function buildCustomMaskPath2D({
bounds,
closed,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
centerX: number;
centerY: number;
rotation: number;
@@ -748,7 +741,7 @@ export function buildCustomMaskPath2D({
return path;
}
- const geometry = getCustomMaskCanvasGeometry({
+ const geometry = getFreeformCanvasGeometry({
points,
centerX,
centerY,
@@ -789,7 +782,7 @@ export function buildCustomMaskPath2D({
return path;
}
-export function buildCustomMaskSvgPath({
+export function buildFreeformSvgPath({
points,
centerX,
centerY,
@@ -798,7 +791,7 @@ export function buildCustomMaskSvgPath({
bounds,
closed,
}: {
- points: CustomMaskPathPoint[];
+ points: FreeformPathPoint[];
centerX: number;
centerY: number;
rotation: number;
@@ -810,7 +803,7 @@ export function buildCustomMaskSvgPath({
return "";
}
- const geometry = getCustomMaskCanvasGeometry({
+ const geometry = getFreeformCanvasGeometry({
points,
centerX,
centerY,
diff --git a/apps/web/src/masks/handle-positions.ts b/apps/web/src/masks/handle-positions.ts
index 75668959..70039d9b 100644
--- a/apps/web/src/masks/handle-positions.ts
+++ b/apps/web/src/masks/handle-positions.ts
@@ -2,6 +2,7 @@ import { FEATHER_HANDLE_SCALE } from "@/masks/feather";
import type { ElementBounds } from "@/preview/element-bounds";
import type {
MaskFeatures,
+ MaskHandleId,
MaskHandlePosition,
MaskLineOverlay,
MaskOverlay,
@@ -68,14 +69,14 @@ export function getLineMaskOverlay({
centerY,
rotation,
bounds,
- handleId = "position",
+ handleId = { kind: "position" },
cursor = "move",
}: {
centerX: number;
centerY: number;
rotation: number;
bounds: ElementBounds;
- handleId?: string;
+ handleId?: MaskHandleId;
cursor?: string;
}): MaskLineOverlay {
const { start, end } = getLineMaskLinePoints({
@@ -122,7 +123,7 @@ export function getLineMaskHandlePositions({
return [
{
- id: "rotation",
+ id: { kind: "rotation" },
x: cx + normalX * iconOffsetCanvas,
y: cy + normalY * iconOffsetCanvas,
cursor: CURSOR.rotate,
@@ -130,7 +131,7 @@ export function getLineMaskHandlePositions({
icon: "rotate",
},
{
- id: "feather",
+ id: { kind: "feather" },
x: cx - normalX * featherOffset,
y: cy - normalY * featherOffset,
cursor: CURSOR.resizeHorizontal,
@@ -201,7 +202,7 @@ export function getBoxMaskHandlePositions({
angleRad,
});
handles.push({
- id: "rotation",
+ id: { kind: "rotation" },
x: rotHandle.x,
y: rotHandle.y,
cursor: CURSOR.rotate,
@@ -217,7 +218,7 @@ export function getBoxMaskHandlePositions({
angleRad,
});
handles.push({
- id: "feather",
+ id: { kind: "feather" },
x: featherHandle.x,
y: featherHandle.y,
cursor: CURSOR.resizeVertical,
@@ -226,16 +227,36 @@ export function getBoxMaskHandlePositions({
});
if (sizeMode === "width-height") {
- const corners = [
- { localX: -halfWidth, localY: -halfHeight, id: "top-left" },
- { localX: halfWidth, localY: -halfHeight, id: "top-right" },
- { localX: halfWidth, localY: halfHeight, id: "bottom-right" },
- { localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
+ const corners: {
+ localX: number;
+ localY: number;
+ corner: Extract<MaskHandleId, { kind: "corner" }>["corner"];
+ }[] = [
+ {
+ localX: -halfWidth,
+ localY: -halfHeight,
+ corner: { x: "left", y: "top" },
+ },
+ {
+ localX: halfWidth,
+ localY: -halfHeight,
+ corner: { x: "right", y: "top" },
+ },
+ {
+ localX: halfWidth,
+ localY: halfHeight,
+ corner: { x: "right", y: "bottom" },
+ },
+ {
+ localX: -halfWidth,
+ localY: halfHeight,
+ corner: { x: "left", y: "bottom" },
+ },
];
- for (const { localX, localY, id } of corners) {
+ for (const { localX, localY, corner } of corners) {
const point = rotatePoint({ localX, localY, cx, cy, angleRad });
handles.push({
- id,
+ id: { kind: "corner", corner },
x: point.x,
y: point.y,
cursor: CURSOR.resizeDiagonal,
@@ -264,7 +285,7 @@ export function getBoxMaskHandlePositions({
angleRad,
});
handles.push({
- id: "left",
+ id: { kind: "edge", side: "left" },
x: left.x,
y: left.y,
cursor: CURSOR.resizeHorizontal,
@@ -273,7 +294,7 @@ export function getBoxMaskHandlePositions({
rotation,
});
handles.push({
- id: "right",
+ id: { kind: "edge", side: "right" },
x: right.x,
y: right.y,
cursor: CURSOR.resizeHorizontal,
@@ -282,7 +303,7 @@ export function getBoxMaskHandlePositions({
rotation,
});
handles.push({
- id: "bottom",
+ id: { kind: "edge", side: "bottom" },
x: bottom.x,
y: bottom.y,
cursor: CURSOR.resizeVertical,
@@ -306,7 +327,7 @@ export function getBoxMaskHandlePositions({
angleRad,
});
handles.push({
- id: "top",
+ id: { kind: "edge", side: "top" },
x: top.x,
y: top.y,
cursor: CURSOR.resizeVertical,
@@ -315,7 +336,7 @@ export function getBoxMaskHandlePositions({
rotation,
});
handles.push({
- id: "bottom",
+ id: { kind: "edge", side: "bottom" },
x: bottom.x,
y: bottom.y,
cursor: CURSOR.resizeVertical,
@@ -339,7 +360,7 @@ export function getBoxMaskHandlePositions({
angleRad,
});
handles.push({
- id: "left",
+ id: { kind: "edge", side: "left" },
x: left.x,
y: left.y,
cursor: CURSOR.resizeHorizontal,
@@ -348,7 +369,7 @@ export function getBoxMaskHandlePositions({
rotation,
});
handles.push({
- id: "right",
+ id: { kind: "edge", side: "right" },
x: right.x,
y: right.y,
cursor: CURSOR.resizeHorizontal,
@@ -365,7 +386,7 @@ export function getBoxMaskHandlePositions({
angleRad,
});
handles.push({
- id: "scale",
+ id: { kind: "scale" },
x: point.x,
y: point.y,
cursor: CURSOR.resizeDiagonal,
@@ -383,7 +404,7 @@ export function getBoxMaskRectOverlay({
height,
rotation,
bounds,
- handleId = "position",
+ handleId = { kind: "position" },
cursor = "move",
dashed = false,
}: {
@@ -393,7 +414,7 @@ export function getBoxMaskRectOverlay({
height: number;
rotation: number;
bounds: ElementBounds;
- handleId?: string;
+ handleId?: MaskHandleId;
cursor?: string;
dashed?: boolean;
}): MaskRectOverlay {
@@ -421,7 +442,7 @@ export function getBoxMaskShapeOverlay({
rotation,
bounds,
pathData,
- handleId = "position",
+ handleId = { kind: "position" },
cursor = "move",
}: {
centerX: number;
@@ -431,7 +452,7 @@ export function getBoxMaskShapeOverlay({
rotation: number;
bounds: ElementBounds;
pathData: string;
- handleId?: string;
+ handleId?: MaskHandleId;
cursor?: string;
}): MaskShapeOverlay {
return {
diff --git a/apps/web/src/masks/index.ts b/apps/web/src/masks/index.ts
index 3544e239..994f05f2 100644
--- a/apps/web/src/masks/index.ts
+++ b/apps/web/src/masks/index.ts
@@ -1,18 +1,58 @@
-import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
-import { masksRegistry } from "./registry";
-import { generateUUID } from "@/utils/id";
-
-export { masksRegistry } from "./registry";
-export { registerDefaultMasks } from "./definitions";
-
-export function buildDefaultMaskInstance({
- maskType,
- elementSize,
-}: {
- maskType: MaskType;
- elementSize?: { width: number; height: number };
-}): Mask {
- const definition = masksRegistry.get(maskType);
- const context: MaskDefaultContext = { elementSize };
- return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
-}
+import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
+import { masksRegistry } from "./registry";
+import { generateUUID } from "@/utils/id";
+
+export { masksRegistry } from "./registry";
+export { registerDefaultMasks } from "./builtin/definitions";
+
+type MaskWithoutId = Mask extends infer TMask
+ ? TMask extends Mask
+ ? Omit<TMask, "id">
+ : never
+ : never;
+
+function withMaskId({ mask, id }: { mask: MaskWithoutId; id: string }): Mask {
+ switch (mask.type) {
+ case "split":
+ return { ...mask, id };
+ case "cinematic-bars":
+ return { ...mask, id };
+ case "rectangle":
+ return { ...mask, id };
+ case "ellipse":
+ return { ...mask, id };
+ case "heart":
+ return { ...mask, id };
+ case "diamond":
+ return { ...mask, id };
+ case "star":
+ return { ...mask, id };
+ case "text":
+ return { ...mask, id };
+ case "freeform":
+ return { ...mask, id };
+ }
+}
+
+export function getMaskDefinition(maskType: MaskType) {
+ return masksRegistry.get(maskType);
+}
+
+export function getMaskDefinitionsForMenu() {
+ return masksRegistry.getAll();
+}
+
+export function buildDefaultMaskInstance({
+ maskType,
+ elementSize,
+}: {
+ maskType: MaskType;
+ elementSize?: { width: number; height: number };
+}): Mask {
+ const definition = masksRegistry.get(maskType);
+ const context: MaskDefaultContext = { elementSize };
+ return withMaskId({
+ mask: definition.buildDefault(context),
+ id: generateUUID(),
+ });
+}
diff --git a/apps/web/src/masks/registry.ts b/apps/web/src/masks/registry.ts
index 00b276c8..35d215c6 100644
--- a/apps/web/src/masks/registry.ts
+++ b/apps/web/src/masks/registry.ts
@@ -2,11 +2,11 @@ import { MAX_FEATHER } from "@/masks/feather";
import type { ParamDefinition } from "@/params";
import type {
BaseMaskParams,
+ Mask,
MaskDefaultContext,
MaskDefinition,
- MaskInteractionResult,
- MaskSnapArgs,
- MaskSnapResult,
+ MaskParamUpdateArgs,
+ MaskRenderer,
MaskType,
} from "@/masks/types";
import type { HugeiconsIconProps } from "@hugeicons/react";
@@ -17,7 +17,17 @@ export type MaskIconProps = {
strokeWidth?: number;
};
-const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
+type RegisteredMaskWithoutId = Mask extends infer TMask
+ ? TMask extends Mask
+ ? Omit<TMask, "id">
+ : never
+ : never;
+
+export type MaskDefinitionForRegistration = {
+ [TType in MaskType]: MaskDefinition<TType>;
+}[MaskType];
+
+export const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
keyof BaseMaskParams & string
>[] = [
{
@@ -50,28 +60,15 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
export interface RegisteredMaskDefinition {
type: MaskType;
name: string;
- features: MaskDefinition<BaseMaskParams>["features"];
+ features: MaskDefinition["features"];
params: ParamDefinition<string>[];
- renderer: MaskDefinition<BaseMaskParams>["renderer"];
- interaction: {
- getInteraction(args: {
- params: BaseMaskParams;
- bounds: Parameters<
- MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"]
- >[0]["bounds"];
- displayScale: number;
- scaleX: number;
- scaleY: number;
- }): MaskInteractionResult;
- snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>;
- };
- isActive?: (params: BaseMaskParams) => boolean;
- buildDefault(
- context: MaskDefaultContext,
- ): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>;
+ renderer: MaskRenderer<BaseMaskParams>;
+ interaction: MaskDefinition["interaction"];
+ isActive?(params: BaseMaskParams): boolean;
+ buildDefault(context: MaskDefaultContext): RegisteredMaskWithoutId;
computeParamUpdate(
- args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0],
- ): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>;
+ args: MaskParamUpdateArgs<BaseMaskParams>,
+ ): Partial<BaseMaskParams>;
icon: MaskIconProps;
}
@@ -83,11 +80,11 @@ export class MasksRegistry extends DefinitionRegistry<
super("mask");
}
- registerMask<TParams extends BaseMaskParams>({
+ registerMask({
definition,
icon,
}: {
- definition: MaskDefinition<TParams>;
+ definition: MaskDefinitionForRegistration;
icon: MaskIconProps;
}): void {
const withBaseParams: RegisteredMaskDefinition = {
@@ -96,26 +93,16 @@ export class MasksRegistry extends DefinitionRegistry<
features: definition.features,
params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
renderer: definition.renderer,
- interaction: {
- getInteraction(args) {
- return definition.interaction.getInteraction(args as never);
- },
- snap: definition.interaction.snap
- ? (args) => definition.interaction.snap?.(args as never) as never
- : undefined,
- },
- isActive: definition.isActive
- ? (params) => definition.isActive?.(params as TParams) ?? true
- : undefined,
- buildDefault(context) {
- return definition.buildDefault(context);
- },
- computeParamUpdate(args) {
- return definition.computeParamUpdate(args as never);
- },
+ interaction: definition.interaction,
+ isActive: definition.isActive,
+ buildDefault: definition.buildDefault,
+ computeParamUpdate: definition.computeParamUpdate,
icon,
};
- this.register(definition.type, withBaseParams);
+ this.register({
+ key: definition.type,
+ definition: withBaseParams,
+ });
}
}
diff --git a/apps/web/src/masks/snap.ts b/apps/web/src/masks/snap.ts
index cff62ebb..519783a9 100644
--- a/apps/web/src/masks/snap.ts
+++ b/apps/web/src/masks/snap.ts
@@ -6,30 +6,20 @@ import {
snapScale,
snapScaleAxes,
type ScaleEdgePreference,
- type SnapLine,
} from "@/preview/preview-snap";
-import type { RectangleMaskParams, SplitMaskParams } from "@/masks/types";
+import type {
+ MaskHandleId,
+ MaskSnapArgs,
+ MaskSnapResult,
+ RectangleMaskParams,
+ SplitMaskParams,
+} from "@/masks/types";
import {
- isRectangleMaskParams,
getMaskSnapGeometry,
setMaskLocalCenter,
toGlobalMaskSnapLines,
} from "./geometry";
-type SharedMaskParams = SplitMaskParams | RectangleMaskParams;
-
-type MaskSnapResult<TParams extends SharedMaskParams> = {
- params: TParams;
- activeLines: SnapLine[];
-};
-
-const CORNER_SIZE_HANDLES = new Set([
- "top-left",
- "top-right",
- "bottom-left",
- "bottom-right",
-]);
-
function getClampedRatio({
next,
base,
@@ -45,28 +35,27 @@ function getClampedRatio({
function getPreferredEdges({
handleId,
}: {
- handleId: string;
+ handleId: MaskHandleId;
}): ScaleEdgePreference | undefined {
- const preferredEdges = {
- left:
- handleId === "left" ||
- handleId === "top-left" ||
- handleId === "bottom-left",
- right:
- handleId === "right" ||
- handleId === "top-right" ||
- handleId === "bottom-right",
- top:
- handleId === "top" || handleId === "top-left" || handleId === "top-right",
- bottom:
- handleId === "bottom" ||
- handleId === "bottom-left" ||
- handleId === "bottom-right",
- } satisfies ScaleEdgePreference;
+ if (handleId.kind === "edge") {
+ return {
+ left: handleId.side === "left",
+ right: handleId.side === "right",
+ top: handleId.side === "top",
+ bottom: handleId.side === "bottom",
+ };
+ }
- return Object.values(preferredEdges).some(Boolean)
- ? preferredEdges
- : undefined;
+ if (handleId.kind === "corner") {
+ return {
+ left: handleId.corner.x === "left",
+ right: handleId.corner.x === "right",
+ top: handleId.corner.y === "top",
+ bottom: handleId.corner.y === "bottom",
+ };
+ }
+
+ return undefined;
}
function snapMaskPosition({
@@ -75,11 +64,33 @@ function snapMaskPosition({
canvasSize,
snapThreshold,
}: {
- proposedParams: SharedMaskParams;
+ proposedParams: RectangleMaskParams;
bounds: ElementBounds;
canvasSize: { width: number; height: number };
snapThreshold: { x: number; y: number };
-}): MaskSnapResult<SharedMaskParams> {
+}): MaskSnapResult<RectangleMaskParams>;
+function snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ proposedParams: SplitMaskParams;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult<SplitMaskParams>;
+function snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: {
+ proposedParams: RectangleMaskParams | SplitMaskParams;
+ bounds: ElementBounds;
+ canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
+}): MaskSnapResult<RectangleMaskParams | SplitMaskParams> {
const geometry = getMaskSnapGeometry({
params: proposedParams,
bounds,
@@ -115,12 +126,18 @@ function snapMaskPosition({
function snapMaskRotation({
proposedParams,
}: {
- proposedParams: SharedMaskParams;
-}): MaskSnapResult<SharedMaskParams> {
- if (typeof proposedParams.rotation !== "number") {
- return { params: proposedParams, activeLines: [] };
- }
-
+ proposedParams: RectangleMaskParams;
+}): MaskSnapResult<RectangleMaskParams>;
+function snapMaskRotation({
+ proposedParams,
+}: {
+ proposedParams: SplitMaskParams;
+}): MaskSnapResult<SplitMaskParams>;
+function snapMaskRotation({
+ proposedParams,
+}: {
+ proposedParams: RectangleMaskParams | SplitMaskParams;
+}): MaskSnapResult<RectangleMaskParams | SplitMaskParams> {
const { snappedRotation } = snapRotation({
proposedRotation: proposedParams.rotation,
});
@@ -142,20 +159,13 @@ function snapBoxMaskSize({
canvasSize,
snapThreshold,
}: {
- handleId: string;
- startParams: SharedMaskParams;
- proposedParams: SharedMaskParams;
+ handleId: MaskHandleId;
+ startParams: RectangleMaskParams;
+ proposedParams: RectangleMaskParams;
bounds: ElementBounds;
canvasSize: { width: number; height: number };
snapThreshold: { x: number; y: number };
-}): MaskSnapResult<SharedMaskParams> {
- if (
- !isRectangleMaskParams(startParams) ||
- !isRectangleMaskParams(proposedParams)
- ) {
- return { params: proposedParams, activeLines: [] };
- }
-
+}): MaskSnapResult<RectangleMaskParams> {
const geometry = getMaskSnapGeometry({
params: proposedParams,
bounds,
@@ -171,7 +181,10 @@ function snapBoxMaskSize({
Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
const preferredEdges = getPreferredEdges({ handleId });
- if (handleId === "right" || handleId === "left") {
+ if (
+ handleId.kind === "edge" &&
+ (handleId.side === "right" || handleId.side === "left")
+ ) {
const proposedScaleX = getClampedRatio({
next: proposedParams.width,
base: startParams.width,
@@ -201,7 +214,10 @@ function snapBoxMaskSize({
};
}
- if (handleId === "top" || handleId === "bottom") {
+ if (
+ handleId.kind === "edge" &&
+ (handleId.side === "top" || handleId.side === "bottom")
+ ) {
const proposedScaleY = getClampedRatio({
next: proposedParams.height,
base: startParams.height,
@@ -234,7 +250,7 @@ function snapBoxMaskSize({
};
}
- if (handleId === "scale") {
+ if (handleId.kind === "scale") {
const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
const proposedScale = getClampedRatio({
next: proposedParams.scale,
@@ -264,7 +280,7 @@ function snapBoxMaskSize({
};
}
- if (CORNER_SIZE_HANDLES.has(handleId)) {
+ if (handleId.kind === "corner") {
const proposedScale = getClampedRatio({
next: proposedParams.width,
base: startParams.width,
@@ -297,7 +313,7 @@ function snapBoxMaskSize({
return { params: proposedParams, activeLines: [] };
}
-export function snapMaskInteraction<TParams extends SharedMaskParams>({
+export function snapBoxMaskInteraction({
handleId,
startParams,
proposedParams,
@@ -305,24 +321,24 @@ export function snapMaskInteraction<TParams extends SharedMaskParams>({
canvasSize,
snapThreshold,
}: {
- handleId: string;
- startParams: TParams;
- proposedParams: TParams;
+ handleId: MaskHandleId;
+ startParams: RectangleMaskParams;
+ proposedParams: RectangleMaskParams;
bounds: ElementBounds;
canvasSize: { width: number; height: number };
snapThreshold: { x: number; y: number };
-}): MaskSnapResult<TParams> {
- if (handleId === "position") {
+}): MaskSnapResult<RectangleMaskParams> {
+ if (handleId.kind === "position") {
return snapMaskPosition({
proposedParams,
bounds,
canvasSize,
snapThreshold,
- }) as MaskSnapResult<TParams>;
+ });
}
- if (handleId === "rotation") {
- return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>;
+ if (handleId.kind === "rotation") {
+ return snapMaskRotation({ proposedParams });
}
return snapBoxMaskSize({
@@ -332,5 +348,28 @@ export function snapMaskInteraction<TParams extends SharedMaskParams>({
bounds,
canvasSize,
snapThreshold,
- }) as MaskSnapResult<TParams>;
+ });
+}
+
+export function snapSplitMaskInteraction({
+ handleId,
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+}: MaskSnapArgs<SplitMaskParams>): MaskSnapResult<SplitMaskParams> {
+ if (handleId.kind === "position") {
+ return snapMaskPosition({
+ proposedParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ });
+ }
+
+ if (handleId.kind === "rotation") {
+ return snapMaskRotation({ proposedParams });
+ }
+
+ return { params: proposedParams, activeLines: [] };
}
diff --git a/apps/web/src/masks/types.ts b/apps/web/src/masks/types.ts
index 4f6c90aa..afc79a8c 100644
--- a/apps/web/src/masks/types.ts
+++ b/apps/web/src/masks/types.ts
@@ -1,14 +1,14 @@
import type { ElementBounds } from "@/preview/element-bounds";
import type { SnapLine } from "@/preview/preview-snap";
import type { ParamDefinition } from "@/params";
-import type { CustomMaskPathPoint } from "@/masks/custom-path";
+import type { FreeformPathPoint } from "@/masks/freeform/path";
import type {
TextDecoration,
TextFontStyle,
TextFontWeight,
} from "@/text/primitives";
-export type MaskType =
+export type BuiltinMaskType =
| "split"
| "cinematic-bars"
| "rectangle"
@@ -16,8 +16,9 @@ export type MaskType =
| "heart"
| "diamond"
| "star"
- | "text"
- | "custom";
+ | "text";
+
+export type MaskType = BuiltinMaskType | "freeform";
export interface BaseMaskParams {
feather: number;
@@ -57,8 +58,8 @@ export interface TextMaskParams extends BaseMaskParams {
scale: number;
}
-export interface CustomMaskParams extends BaseMaskParams {
- path: CustomMaskPathPoint[];
+export interface FreeformPathMaskParams extends BaseMaskParams {
+ path: FreeformPathPoint[];
closed: boolean;
centerX: number;
centerY: number;
@@ -114,13 +115,7 @@ export interface TextMask {
params: TextMaskParams;
}
-export interface CustomMask {
- id: string;
- type: "custom";
- params: CustomMaskParams;
-}
-
-export type Mask =
+export type BuiltinShapeMask =
| SplitMask
| CinematicBarsMask
| RectangleMask
@@ -128,35 +123,60 @@ export type Mask =
| HeartMask
| DiamondMask
| StarMask
- | TextMask
- | CustomMask;
+ | TextMask;
+
+export interface FreeformPathMask {
+ id: string;
+ type: "freeform";
+ params: FreeformPathMaskParams;
+}
+
+export type Mask = BuiltinShapeMask | FreeformPathMask;
+
+export type MaskByType<TType extends MaskType> = Extract<Mask, { type: TType }>;
+export type MaskParamsByType<TType extends MaskType> =
+ MaskByType<TType>["params"];
+
+type MaskPathArgs<TParams extends BaseMaskParams> = {
+ resolvedParams: TParams;
+ width: number;
+ height: number;
+};
+
+type MaskDrawArgs<TParams extends BaseMaskParams> = MaskPathArgs<TParams> & {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+};
+
+export type MaskBody<TParams extends BaseMaskParams = BaseMaskParams> =
+ | {
+ kind: "fillPath";
+ buildPath(args: MaskPathArgs<TParams>): Path2D;
+ }
+ | {
+ kind: "drawOpaque";
+ drawOpaque(args: MaskDrawArgs<TParams>): void;
+ }
+ | {
+ kind: "drawWithFeather";
+ drawWithFeather(args: MaskDrawArgs<TParams> & { feather: number }): void;
+ opaqueFastPath?: {
+ buildPath(args: MaskPathArgs<TParams>): Path2D;
+ };
+ };
-export interface MaskRenderer {
- buildPath?: (params: {
- resolvedParams: unknown;
- width: number;
- height: number;
- }) => Path2D;
- buildStrokePath?: (params: {
- resolvedParams: unknown;
- width: number;
- height: number;
- }) => Path2D;
- /** Renders the feathered mask directly onto ctx, bypassing JFA. */
- renderMask?: (params: {
- resolvedParams: unknown;
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- feather: number;
- }) => void;
- renderMaskHandlesFeather?: boolean;
- renderStroke?: (params: {
- resolvedParams: unknown;
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- }) => void;
+export type MaskStroke<TParams extends BaseMaskParams = BaseMaskParams> =
+ | {
+ kind: "strokeFromPath";
+ buildStrokePath(args: MaskPathArgs<TParams>): Path2D;
+ }
+ | {
+ kind: "renderStroke";
+ renderStroke(args: MaskDrawArgs<TParams>): void;
+ };
+
+export interface MaskRenderer<TParams extends BaseMaskParams = BaseMaskParams> {
+ body: MaskBody<TParams>;
+ stroke?: MaskStroke<TParams>;
}
export interface MaskFeatures {
@@ -167,10 +187,41 @@ export interface MaskFeatures {
export type MaskHandleIcon = "rotate" | "feather";
-export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent";
+export type MaskHandleKind = "corner" | "edge" | "icon" | "point";
+
+type Side = "left" | "right" | "top" | "bottom";
+type CornerXY = { x: "left" | "right"; y: "top" | "bottom" };
+
+export type MaskHandleId =
+ | { kind: "position" }
+ | { kind: "rotation" }
+ | { kind: "feather" }
+ | { kind: "scale" }
+ | { kind: "edge"; side: Side }
+ | { kind: "corner"; corner: CornerXY }
+ | { kind: "anchor"; pointId: string }
+ | { kind: "segment"; index: number };
+
+export function maskHandleIdKey({ id }: { id: MaskHandleId }): string {
+ switch (id.kind) {
+ case "position":
+ case "rotation":
+ case "feather":
+ case "scale":
+ return id.kind;
+ case "edge":
+ return id.side;
+ case "corner":
+ return `${id.corner.y}-${id.corner.x}`;
+ case "anchor":
+ return `point:${id.pointId}:anchor`;
+ case "segment":
+ return `segment:${id.index}`;
+ }
+}
export interface MaskHandlePosition {
- id: string;
+ id: MaskHandleId;
x: number;
y: number;
cursor: string;
@@ -187,7 +238,7 @@ export interface MaskLineOverlay {
start: { x: number; y: number };
end: { x: number; y: number };
cursor?: string;
- handleId?: string;
+ handleId?: MaskHandleId;
}
export interface MaskRectOverlay {
@@ -199,7 +250,7 @@ export interface MaskRectOverlay {
rotation: number;
dashed?: boolean;
cursor?: string;
- handleId?: string;
+ handleId?: MaskHandleId;
}
export interface MaskShapeOverlay {
@@ -211,7 +262,7 @@ export interface MaskShapeOverlay {
rotation: number;
pathData: string;
cursor?: string;
- handleId?: string;
+ handleId?: MaskHandleId;
}
export interface MaskCanvasPathOverlay {
@@ -220,7 +271,7 @@ export interface MaskCanvasPathOverlay {
pathData: string;
coordinateSpace?: "canvas" | "overlay";
cursor?: string;
- handleId?: string;
+ handleId?: MaskHandleId;
strokeWidth?: number;
strokeOpacity?: number;
}
@@ -238,7 +289,7 @@ export interface MaskDefaultContext {
export interface MaskParamUpdateArgs<
TParams extends BaseMaskParams = BaseMaskParams,
> {
- handleId: string;
+ handleId: MaskHandleId;
startParams: TParams;
deltaX: number;
deltaY: number;
@@ -249,7 +300,7 @@ export interface MaskParamUpdateArgs<
}
export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> {
- handleId: string;
+ handleId: MaskHandleId;
startParams: TParams;
proposedParams: TParams;
bounds: ElementBounds;
@@ -282,17 +333,17 @@ export interface MaskInteractionDefinition<
snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
}
-export interface MaskDefinition<
- TParams extends BaseMaskParams = BaseMaskParams,
-> {
- type: MaskType;
+export interface MaskDefinition<TType extends MaskType = MaskType> {
+ type: TType;
name: string;
features: MaskFeatures;
- params: ParamDefinition<keyof TParams & string>[];
- renderer: MaskRenderer;
- interaction: MaskInteractionDefinition<TParams>;
+ params: ParamDefinition<keyof MaskParamsByType<TType> & string>[];
+ renderer: MaskRenderer<MaskParamsByType<TType>>;
+ interaction: MaskInteractionDefinition<MaskParamsByType<TType>>;
/** When defined and returning false, the mask is not applied and the element renders fully visible. */
- isActive?: (params: TParams) => boolean;
- buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
- computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>;
+ isActive?(params: MaskParamsByType<TType>): boolean;
+ buildDefault(context: MaskDefaultContext): Omit<MaskByType<TType>, "id">;
+ computeParamUpdate(
+ args: MaskParamUpdateArgs<MaskParamsByType<TType>>,
+ ): Partial<MaskParamsByType<TType>>;
}
diff --git a/apps/web/src/masks/use-mask-handles.ts b/apps/web/src/masks/use-mask-handles.ts
index b81de4f4..f889e029 100644
--- a/apps/web/src/masks/use-mask-handles.ts
+++ b/apps/web/src/masks/use-mask-handles.ts
@@ -1,13 +1,9 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useEditor } from "@/editor/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
-import { masksRegistry } from "@/masks";
-import {
- parseCustomMaskHandleId,
- parseCustomMaskSegmentHandleId,
-} from "@/masks/custom-path";
-import { appendPointToCustomMask } from "@/masks/definitions/custom";
+import { getMaskDefinition } from "@/masks";
+import { appendPointToFreeformPathMask } from "@/masks/freeform/definition";
import {
getVisibleElementsWithBounds,
type ElementBounds,
@@ -17,14 +13,15 @@ import {
type SnapLine,
} from "@/preview/preview-snap";
import type { SelectedMaskPointSelection } from "@/selection/editor-selection";
-import type { Mask, MaskInteractionResult } from "@/masks/types";
+import type { Mask, MaskHandleId, MaskInteractionResult } from "@/masks/types";
import type { MaskableElement } from "@/timeline";
+import { isMaskableElement } from "@/timeline/element-utils";
import { registerCanceller } from "@/editor/cancel-interaction";
interface DragState {
trackId: string;
elementId: string;
- handleId: string;
+ handleId: MaskHandleId;
startCanvasX: number;
startCanvasY: number;
startParams: Mask["params"];
@@ -99,12 +96,12 @@ export function useMaskHandles({
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const viewport = usePreviewViewport();
- const [activeHandleId, setActiveHandleId] = useState<string | null>(null);
+ const [activeHandleId, setActiveHandleId] = useState<MaskHandleId | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>(
null,
);
- const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
+ const captureRef = useRef<{ element: Element; pointerId: number } | null>(
null,
);
@@ -137,7 +134,8 @@ export function useMaskHandles({
item.trackId === sel.trackId && item.elementId === sel.elementId,
);
if (!entry) return null;
- const element = entry.element as MaskableElement;
+ if (!isMaskableElement(entry.element)) return null;
+ const element = entry.element;
const masks = element.masks ?? [];
if (masks.length === 0) return null;
const activeMaskId = masks.some((mask) =>
@@ -160,7 +158,7 @@ export function useMaskHandles({
const { handles: baseHandlePositions, overlays }: MaskInteractionResult =
selectedWithMask
? (() => {
- const def = masksRegistry.get(selectedWithMask.mask.type);
+ const def = getMaskDefinition(selectedWithMask.mask.type);
const { x: scaleX, y: scaleY } = viewport.getDisplayScale();
const displayScale = (scaleX + scaleY) / 2;
return def.interaction.getInteraction({
@@ -186,30 +184,29 @@ export function useMaskHandles({
);
const handlePositions = baseHandlePositions.map((handle) => {
- const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id });
- if (!parsedHandle || parsedHandle.part !== "anchor") {
+ if (handle.id.kind !== "anchor") {
return handle;
}
return {
...handle,
- isSelected: selectedPointIds.has(parsedHandle.pointId),
+ isSelected: selectedPointIds.has(handle.id.pointId),
};
});
- const customMaskPointIds =
- selectedWithMask?.mask.type === "custom"
- ? handlePositions
- .filter((h) => h.kind === "point")
- .map((h) => {
- const parsedHandle = parseCustomMaskHandleId({ handleId: h.id });
- return parsedHandle?.part === "anchor"
- ? parsedHandle.pointId
- : null;
- })
- .filter((id): id is string => id !== null)
- : [];
- const isCreatingCustomMask =
- selectedWithMask?.mask.type === "custom" &&
+ const customMaskPointIds = useMemo(
+ () =>
+ selectedWithMask?.mask.type === "freeform"
+ ? handlePositions
+ .filter((h) => h.kind === "point")
+ .map((h) => {
+ return h.id.kind === "anchor" ? h.id.pointId : null;
+ })
+ .filter((id): id is string => id !== null)
+ : [],
+ [handlePositions, selectedWithMask?.mask.type],
+ );
+ const isCreatingFreeformPathMask =
+ selectedWithMask?.mask.type === "freeform" &&
(!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0);
useEffect(() => {
@@ -218,7 +215,7 @@ export function useMaskHandles({
}
if (
!selectedWithMask ||
- selectedWithMask.mask.type !== "custom" ||
+ selectedWithMask.mask.type !== "freeform" ||
!isMaskSelectionForElement({
trackId: selectedWithMask.trackId,
elementId: selectedWithMask.elementId,
@@ -257,7 +254,7 @@ export function useMaskHandles({
selectedWithMask,
]);
- const updateCustomMaskPointSelection = useCallback(
+ const updateFreeformPathMaskPointSelection = useCallback(
({
pointId,
toggleSelection,
@@ -265,7 +262,7 @@ export function useMaskHandles({
pointId: string;
toggleSelection: boolean;
}) => {
- if (!selectedWithMask || selectedWithMask.mask.type !== "custom") {
+ if (!selectedWithMask || selectedWithMask.mask.type !== "freeform") {
return;
}
@@ -339,25 +336,32 @@ export function useMaskHandles({
]);
const handlePointerDown = useCallback(
- ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => {
+ ({
+ event,
+ handleId,
+ }: {
+ event: React.PointerEvent;
+ handleId: MaskHandleId;
+ }) => {
if (!selectedWithMask) return;
if (event.button !== 0) return;
event.stopPropagation();
- const parsedHandle =
- selectedWithMask.mask.type === "custom"
- ? parseCustomMaskHandleId({ handleId })
+ const anchorHandle =
+ selectedWithMask.mask.type === "freeform" && handleId.kind === "anchor"
+ ? handleId
: null;
- const parsedSegmentHandle =
- selectedWithMask.mask.type === "custom"
- ? parseCustomMaskSegmentHandleId({ handleId })
+ const segmentHandle =
+ selectedWithMask.mask.type === "freeform" && handleId.kind === "segment"
+ ? handleId
: null;
- if (isCreatingCustomMask) {
+ if (isCreatingFreeformPathMask) {
const firstPointId = customMaskPointIds[0];
if (
firstPointId &&
- handleId === `point:${firstPointId}:anchor` &&
+ handleId.kind === "anchor" &&
+ handleId.pointId === firstPointId &&
customMaskPointIds.length >= 3 &&
- selectedWithMask.mask.type === "custom"
+ selectedWithMask.mask.type === "freeform"
) {
const updatedMask = withUpdatedMaskParams({
mask: selectedWithMask.mask,
@@ -390,13 +394,13 @@ export function useMaskHandles({
});
if (!pos) return;
- if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") {
+ if (segmentHandle && selectedWithMask.mask.type === "freeform") {
setActiveHandleId(handleId);
pendingSegmentInsertRef.current = {
trackId: selectedWithMask.trackId,
elementId: selectedWithMask.elementId,
maskId: selectedWithMask.mask.id,
- segmentIndex: parsedSegmentHandle.segmentIndex,
+ segmentIndex: segmentHandle.index,
startClientX: event.clientX,
startClientY: event.clientY,
startCanvasX: pos.x,
@@ -404,7 +408,7 @@ export function useMaskHandles({
startParams: { ...selectedWithMask.mask.params },
bounds: selectedWithMask.bounds,
};
- const captureTarget = event.currentTarget as HTMLElement;
+ const captureTarget = event.currentTarget;
captureTarget.setPointerCapture(event.pointerId);
captureRef.current = {
element: captureTarget,
@@ -413,15 +417,15 @@ export function useMaskHandles({
return;
}
- if (parsedHandle?.part === "anchor") {
- updateCustomMaskPointSelection({
- pointId: parsedHandle.pointId,
+ if (anchorHandle) {
+ updateFreeformPathMaskPointSelection({
+ pointId: anchorHandle.pointId,
toggleSelection: event.shiftKey,
});
if (event.shiftKey) {
return;
}
- } else if (selectedWithMask.mask.type === "custom") {
+ } else if (selectedWithMask.mask.type === "freeform") {
editor.selection.clearMaskPointSelection();
}
@@ -434,7 +438,7 @@ export function useMaskHandles({
startParams: { ...selectedWithMask.mask.params },
};
setActiveHandleId(handleId);
- const captureTarget = event.currentTarget as HTMLElement;
+ const captureTarget = event.currentTarget;
captureTarget.setPointerCapture(event.pointerId);
captureRef.current = {
element: captureTarget,
@@ -445,22 +449,22 @@ export function useMaskHandles({
customMaskPointIds,
editor.selection,
editor.timeline,
- isCreatingCustomMask,
+ isCreatingFreeformPathMask,
selectedWithMask,
- updateCustomMaskPointSelection,
+ updateFreeformPathMaskPointSelection,
viewport,
],
);
const handleCanvasPointerDown = useCallback(
({ event }: { event: React.PointerEvent }) => {
- if (!selectedWithMask || !isCreatingCustomMask) {
+ if (!selectedWithMask || !isCreatingFreeformPathMask) {
return;
}
if (event.button !== 0) {
return;
}
- if (selectedWithMask.mask.type !== "custom") {
+ if (selectedWithMask.mask.type !== "freeform") {
return;
}
@@ -473,7 +477,7 @@ export function useMaskHandles({
return;
}
- const nextParams = appendPointToCustomMask({
+ const nextParams = appendPointToFreeformPathMask({
params: selectedWithMask.mask.params,
canvasPoint: pos,
bounds: selectedWithMask.bounds,
@@ -498,7 +502,7 @@ export function useMaskHandles({
],
});
},
- [editor.timeline, isCreatingCustomMask, selectedWithMask, viewport],
+ [editor.timeline, isCreatingFreeformPathMask, selectedWithMask, viewport],
);
const handlePointerMove = useCallback(
@@ -513,13 +517,13 @@ export function useMaskHandles({
dragStateRef.current = {
trackId: pendingSegmentInsert.trackId,
elementId: pendingSegmentInsert.elementId,
- handleId: "position",
+ handleId: { kind: "position" },
startCanvasX: pendingSegmentInsert.startCanvasX,
startCanvasY: pendingSegmentInsert.startCanvasY,
startParams: pendingSegmentInsert.startParams,
};
pendingSegmentInsertRef.current = null;
- setActiveHandleId("position");
+ setActiveHandleId({ kind: "position" });
}
}
@@ -534,7 +538,7 @@ export function useMaskHandles({
const deltaX = pos.x - drag.startCanvasX;
const deltaY = pos.y - drag.startCanvasY;
- const def = masksRegistry.get(selectedWithMask.mask.type);
+ const def = getMaskDefinition(selectedWithMask.mask.type);
const rawParams = def.computeParamUpdate({
handleId: drag.handleId,
@@ -596,7 +600,7 @@ export function useMaskHandles({
const handlePointerUp = useCallback(() => {
const pendingSegmentInsert = pendingSegmentInsertRef.current;
if (pendingSegmentInsert && !dragStateRef.current) {
- editor.timeline.insertCustomMaskPoint({
+ editor.timeline.insertFreeformPathMaskPoint({
trackId: pendingSegmentInsert.trackId,
elementId: pendingSegmentInsert.elementId,
maskId: pendingSegmentInsert.maskId,
@@ -623,7 +627,7 @@ export function useMaskHandles({
selectedWithMask,
handlePositions,
overlays,
- isCreatingCustomMask,
+ isCreatingFreeformPathMask,
handleCanvasPointerDown,
activeHandleId,
handlePointerDown,
diff --git a/apps/web/src/media/audio.ts b/apps/web/src/media/audio.ts
index 42756d49..7abaf90e 100644
--- a/apps/web/src/media/audio.ts
+++ b/apps/web/src/media/audio.ts
@@ -11,6 +11,7 @@ import { applyAudioMasteringToBuffer } from "@/media/audio-mastering";
import type { AudioCapableElement } from "@/timeline/audio-state";
import {
hasAnimatedVolume,
+ isElementMuted,
resolveEffectiveAudioGain,
} from "@/timeline/audio-state";
import { doesElementHaveEnabledAudio } from "@/timeline/audio-separation";
@@ -128,7 +129,7 @@ export function timelineHasAudio({
mediaAssets: MediaAsset[];
}): boolean {
return collectAudibleCandidates({ tracks, mediaAssets }).some(
- ({ element }) => element.muted !== true,
+ ({ element }) => !isElementMuted({ element }),
);
}
@@ -168,7 +169,7 @@ export async function collectAudioElements({
trackMuted: false,
localTime: 0,
}),
- muted: element.muted === true,
+ muted: isElementMuted({ element }),
retime: element.retime,
};
}),
@@ -197,7 +198,7 @@ export async function collectAudioElements({
trackMuted: false,
localTime: 0,
}),
- muted: element.muted ?? false,
+ muted: isElementMuted({ element }),
retime: element.retime,
};
}),
@@ -501,7 +502,7 @@ export async function collectAudioMixSources({
for (const element of track.elements) {
if (!canElementHaveAudio(element)) continue;
- if (element.muted === true) continue;
+ if (isElementMuted({ element })) continue;
const mediaAsset = hasMediaId(element)
? (mediaMap.get(element.mediaId) ?? null)
: null;
@@ -570,9 +571,7 @@ export async function collectAudioClips({
: null;
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
- const isElementMuted =
- "muted" in element ? (element.muted ?? false) : false;
- const muted = isTrackMuted || isElementMuted;
+ const muted = isTrackMuted || isElementMuted({ element });
const volume = resolveEffectiveAudioGain({
element,
trackMuted: isTrackMuted,
diff --git a/apps/web/src/media/use-paste-media.ts b/apps/web/src/media/use-paste-media.ts
index 3055920e..7dbd4078 100644
--- a/apps/web/src/media/use-paste-media.ts
+++ b/apps/web/src/media/use-paste-media.ts
@@ -7,7 +7,7 @@ import { AddMediaAssetCommand } from "@/commands/media";
import { InsertElementCommand } from "@/commands/timeline";
import { BatchCommand } from "@/commands";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
-import { TICKS_PER_SECOND } from "@/wasm";
+import { mediaTimeFromSeconds } from "@/wasm";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/media/types";
@@ -68,14 +68,14 @@ export function usePasteMedia() {
const startTime = editor.playback.getCurrentTime();
for (const asset of processedAssets) {
- const addMediaCmd = new AddMediaAssetCommand(
- activeProject.metadata.id,
+ const addMediaCmd = new AddMediaAssetCommand({
+ projectId: activeProject.metadata.id,
asset,
- );
+ });
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration != null
- ? Math.round(asset.duration * TICKS_PER_SECOND)
+ ? mediaTimeFromSeconds({ seconds: asset.duration })
: DEFAULT_NEW_ELEMENT_DURATION;
const trackType = asset.type === "audio" ? "audio" : "video";
diff --git a/apps/web/src/params/__tests__/channel-layout.test.ts b/apps/web/src/params/__tests__/channel-layout.test.ts
new file mode 100644
index 00000000..e058418d
--- /dev/null
+++ b/apps/web/src/params/__tests__/channel-layout.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, test } from "bun:test";
+import { formatLinearRgba } from "@/params";
+
+describe("channel layout", () => {
+ test("formats linear animated colors as hex", () => {
+ expect(formatLinearRgba({ color: { r: 1, g: 0, b: 0, a: 1 } })).toBe(
+ "#ff0000",
+ );
+ });
+});
diff --git a/apps/web/src/params/index.ts b/apps/web/src/params/index.ts
index 633bfbd0..fa2550f8 100644
--- a/apps/web/src/params/index.ts
+++ b/apps/web/src/params/index.ts
@@ -1,50 +1,342 @@
-export type ParamValues = Record<string, number | string | boolean>;
-
-export type ParamGroup = "stroke";
-
-interface BaseParamDefinition<TKey extends string = string> {
- key: TKey;
- label: string;
- group?: ParamGroup;
-}
-
-export interface NumberParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "number";
- default: number;
- min: number;
- max?: number;
- step: number;
- /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
- displayMultiplier?: number;
- /** Show as percentage of max. min/max/step/default stay in stored space. */
- unit?: "percent";
- /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
- shortLabel?: string;
-}
-
-export interface BooleanParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "boolean";
- default: boolean;
-}
-
-export interface ColorParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "color";
- default: string;
-}
-
-export interface SelectParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "select";
- default: string;
- options: Array<{ value: string; label: string }>;
-}
-
-export type ParamDefinition<TKey extends string = string> =
- | NumberParamDefinition<TKey>
- | BooleanParamDefinition<TKey>
- | ColorParamDefinition<TKey>
- | SelectParamDefinition<TKey>;
-
+import { converter, formatHex, formatHex8, parse } from "culori";
+import { clamp, snapToStep } from "@/utils/math";
+
+export type ParamValue = number | string | boolean;
+export type ParamValues = Record<string, ParamValue>;
+
+export type ParamGroup = "stroke";
+
+export type ChannelValueKind = "scalar" | "discrete";
+export type ChannelEasingMode = "independent" | "shared";
+
+export interface LinearRgba {
+ r: number;
+ g: number;
+ b: number;
+ a: number;
+}
+
+export interface ChannelComponentDefinition<TKey extends string = string> {
+ key: TKey;
+ valueKind: ChannelValueKind;
+ defaultInterpolation: "linear" | "hold";
+}
+
+export interface LeafChannelLayout<TValue extends ParamValue = ParamValue> {
+ kind: "leaf";
+ component: ChannelComponentDefinition<"value">;
+ easingMode: "independent";
+ decompose: (value: TValue) => { value: TValue };
+ compose: (components: { value?: TValue }) => TValue | null;
+}
+
+export interface CompositeChannelLayout<
+ TValue extends ParamValue = ParamValue,
+ TComponents extends object = Record<string, ParamValue>,
+> {
+ kind: "composite";
+ components: Array<ChannelComponentDefinition<keyof TComponents & string>>;
+ easingMode: ChannelEasingMode;
+ decompose: (value: TValue) => TComponents | null;
+ compose: (components: Partial<TComponents>) => TValue | null;
+}
+
+export type ChannelLayout<
+ TValue extends ParamValue = ParamValue,
+ TComponents extends object = Record<string, ParamValue>,
+> = LeafChannelLayout<TValue> | CompositeChannelLayout<TValue, TComponents>;
+
+export type ParamChannelLayout =
+ | LeafChannelLayout<number>
+ | LeafChannelLayout<boolean>
+ | LeafChannelLayout<string>
+ | CompositeChannelLayout<string, LinearRgba>;
+
+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;
+}
+
+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: number;
+ g: number;
+ b: number;
+ alpha: number;
+ } = {
+ 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 }),
+ };
+ return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
+}
+
+function createLeafChannelLayout<TValue extends ParamValue>({
+ valueKind,
+ defaultInterpolation,
+}: {
+ valueKind: ChannelValueKind;
+ defaultInterpolation: "linear" | "hold";
+}): LeafChannelLayout<TValue> {
+ return {
+ kind: "leaf",
+ component: {
+ key: "value",
+ valueKind,
+ defaultInterpolation,
+ },
+ easingMode: "independent",
+ decompose: (value) => ({ value }),
+ compose: ({ value }) => value ?? null,
+ };
+}
+
+export const NUMBER_CHANNEL_LAYOUT: LeafChannelLayout<number> =
+ createLeafChannelLayout<number>({
+ valueKind: "scalar",
+ defaultInterpolation: "linear",
+ });
+
+export const BOOLEAN_CHANNEL_LAYOUT: LeafChannelLayout<boolean> =
+ createLeafChannelLayout<boolean>({
+ valueKind: "discrete",
+ defaultInterpolation: "hold",
+ });
+
+export const STRING_CHANNEL_LAYOUT: LeafChannelLayout<string> =
+ createLeafChannelLayout<string>({
+ valueKind: "discrete",
+ defaultInterpolation: "hold",
+ });
+
+const colorComponent = (
+ key: keyof LinearRgba,
+): ChannelComponentDefinition<keyof LinearRgba> => ({
+ key,
+ valueKind: "scalar",
+ defaultInterpolation: "linear",
+});
+
+export const COLOR_CHANNEL_LAYOUT: CompositeChannelLayout<string, LinearRgba> = {
+ kind: "composite",
+ components: [
+ colorComponent("r"),
+ colorComponent("g"),
+ colorComponent("b"),
+ colorComponent("a"),
+ ],
+ easingMode: "shared",
+ decompose: (value) => parseColorToLinearRgba({ color: value }),
+ compose: ({ r, g, b, a }) =>
+ typeof r === "number" &&
+ typeof g === "number" &&
+ typeof b === "number" &&
+ typeof a === "number"
+ ? formatLinearRgba({ color: { r, g, b, a } })
+ : null,
+};
+
+interface BaseParamDefinition<TKey extends string = string> {
+ key: TKey;
+ label: string;
+ group?: ParamGroup;
+ keyframable?: boolean;
+ dependencies?: Array<{ param: string; equals: ParamValue }>;
+}
+
+export interface NumberParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "number";
+ default: number;
+ channels?: LeafChannelLayout<number>;
+ min: number;
+ max?: number;
+ step: number;
+ /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
+ displayMultiplier?: number;
+ /** Show as percentage of max. min/max/step/default stay in stored space. */
+ unit?: "percent";
+ /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
+ shortLabel?: string;
+}
+
+export interface BooleanParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "boolean";
+ default: boolean;
+ channels?: LeafChannelLayout<boolean>;
+}
+
+export interface ColorParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "color";
+ default: string;
+ channels?: ChannelLayout<string, LinearRgba>;
+}
+
+export interface SelectParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "select";
+ default: string;
+ channels?: LeafChannelLayout<string>;
+ options: Array<{ value: string; label: string }>;
+}
+
+export interface TextParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "text";
+ default: string;
+ channels?: LeafChannelLayout<string>;
+}
+
+export interface FontParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "font";
+ default: string;
+ channels?: LeafChannelLayout<string>;
+}
+
+export type ParamDefinition<TKey extends string = string> =
+ | NumberParamDefinition<TKey>
+ | BooleanParamDefinition<TKey>
+ | ColorParamDefinition<TKey>
+ | SelectParamDefinition<TKey>
+ | TextParamDefinition<TKey>
+ | FontParamDefinition<TKey>;
+
+export function getParamChannelLayout({
+ param,
+}: {
+ param: ParamDefinition;
+}): ParamChannelLayout {
+ switch (param.type) {
+ case "number":
+ return param.channels ?? NUMBER_CHANNEL_LAYOUT;
+ case "boolean":
+ return param.channels ?? BOOLEAN_CHANNEL_LAYOUT;
+ case "color":
+ return param.channels ?? COLOR_CHANNEL_LAYOUT;
+ case "select":
+ case "text":
+ case "font":
+ return param.channels ?? STRING_CHANNEL_LAYOUT;
+ default: {
+ const exhaustive: never = param;
+ return exhaustive;
+ }
+ }
+}
+
+export function getParamValueKind({
+ param,
+}: {
+ param: ParamDefinition;
+}): "number" | "color" | "discrete" {
+ const layout = getParamChannelLayout({ param });
+ if (layout.kind === "composite") {
+ return "color";
+ }
+ if (layout.component.valueKind === "scalar") {
+ return "number";
+ }
+ return "discrete";
+}
+
+export function getParamDefaultInterpolation({
+ param,
+}: {
+ param: ParamDefinition;
+}): "linear" | "hold" {
+ const layout = getParamChannelLayout({ param });
+ if (layout.kind === "leaf") {
+ return layout.component.defaultInterpolation;
+ }
+ return layout.components[0]?.defaultInterpolation ?? "linear";
+}
+
+export function getParamNumericRange({
+ param,
+}: {
+ param: ParamDefinition;
+}): { min?: number; max?: number; step?: number } | undefined {
+ if (param.type !== "number") {
+ return undefined;
+ }
+
+ return {
+ min: param.min,
+ max: param.max,
+ step: param.step,
+ };
+}
+
+export function coerceParamValue({
+ param,
+ value,
+}: {
+ param: ParamDefinition;
+ value: unknown;
+}): ParamValue | null {
+ switch (param.type) {
+ case "number": {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ const steppedValue = snapToStep({ value, step: param.step });
+ const maxValue = param.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(param.min, steppedValue));
+ }
+ case "boolean":
+ return typeof value === "boolean" ? value : null;
+ case "color":
+ case "text":
+ case "font":
+ return typeof value === "string" ? value : null;
+ case "select":
+ return typeof value === "string" &&
+ param.options.some((option) => option.value === value)
+ ? value
+ : null;
+ default: {
+ const exhaustive: never = param;
+ return exhaustive;
+ }
+ }
+}
diff --git a/apps/web/src/params/registry.ts b/apps/web/src/params/registry.ts
index d9fcc266..9e363c88 100644
--- a/apps/web/src/params/registry.ts
+++ b/apps/web/src/params/registry.ts
@@ -1,40 +1,438 @@
-import type { ParamDefinition, ParamValues } from "@/params";
-
-export function buildDefaultParamValues(
- params: ParamDefinition[],
-): ParamValues {
- const values: ParamValues = {};
- for (const param of params) {
- values[param.key] = param.default;
- }
- return values;
-}
-
-export class DefinitionRegistry<TKey extends string, TDefinition> {
- private definitions = new Map<TKey, TDefinition>();
- private entityName: string;
-
- constructor(entityName: string) {
- this.entityName = entityName;
- }
-
- register(key: TKey, definition: TDefinition): void {
- this.definitions.set(key, definition);
- }
-
- has(key: TKey): boolean {
- return this.definitions.has(key);
- }
-
- get(key: TKey): TDefinition {
- const def = this.definitions.get(key);
- if (!def) {
- throw new Error(`Unknown ${this.entityName}: ${key}`);
- }
- return def;
- }
-
- getAll(): TDefinition[] {
- return Array.from(this.definitions.values());
- }
-}
+import type {
+ ParamDefinition,
+ ParamValue,
+ ParamValues,
+} from "@/params";
+import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
+import type { BlendMode } from "@/rendering";
+import type {
+ ElementType,
+ TimelineElement,
+} from "@/timeline";
+import { DEFAULTS } from "@/timeline/defaults";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+} from "@/text/background";
+
+export type ElementParamDefinition<TKey extends string = string> =
+ ParamDefinition<TKey> & {
+ read?: ({ element }: { element: TimelineElement }) => ParamValue | null;
+ write?: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: ParamValue;
+ }) => TimelineElement;
+ };
+
+export function buildDefaultParamValues(
+ params: readonly ParamDefinition[],
+): ParamValues {
+ const values: ParamValues = {};
+ for (const param of params) {
+ values[param.key] = param.default;
+ }
+ return values;
+}
+
+export class DefinitionRegistry<TKey extends string, TDefinition> {
+ private definitions = new Map<TKey, TDefinition>();
+ private entityName: string;
+
+ constructor(entityName: string) {
+ this.entityName = entityName;
+ }
+
+ register({
+ key,
+ definition,
+ }: {
+ key: TKey;
+ definition: TDefinition;
+ }): void {
+ this.definitions.set(key, definition);
+ }
+
+ has(key: TKey): boolean {
+ return this.definitions.has(key);
+ }
+
+ get(key: TKey): TDefinition {
+ const def = this.definitions.get(key);
+ if (!def) {
+ throw new Error(`Unknown ${this.entityName}: ${key}`);
+ }
+ return def;
+ }
+
+ getAll(): TDefinition[] {
+ return Array.from(this.definitions.values());
+ }
+}
+
+const BLEND_MODE_OPTIONS: Array<{ value: BlendMode; label: string }> = [
+ { value: "normal", label: "Normal" },
+ { value: "darken", label: "Darken" },
+ { value: "multiply", label: "Multiply" },
+ { value: "color-burn", label: "Color Burn" },
+ { value: "lighten", label: "Lighten" },
+ { value: "screen", label: "Screen" },
+ { value: "plus-lighter", label: "Plus Lighter" },
+ { value: "color-dodge", label: "Color Dodge" },
+ { value: "overlay", label: "Overlay" },
+ { value: "soft-light", label: "Soft Light" },
+ { value: "hard-light", label: "Hard Light" },
+ { value: "difference", label: "Difference" },
+ { value: "exclusion", label: "Exclusion" },
+ { value: "hue", label: "Hue" },
+ { value: "saturation", label: "Saturation" },
+ { value: "color", label: "Color" },
+ { value: "luminosity", label: "Luminosity" },
+];
+
+const visualElementParams: ElementParamDefinition[] = [
+ {
+ key: "transform.positionX",
+ label: "Position X",
+ type: "number",
+ default: DEFAULTS.element.transform.position.x,
+ min: -100_000,
+ step: 1,
+ },
+ {
+ key: "transform.positionY",
+ label: "Position Y",
+ type: "number",
+ default: DEFAULTS.element.transform.position.y,
+ min: -100_000,
+ step: 1,
+ },
+ {
+ key: "transform.scaleX",
+ label: "Scale X",
+ type: "number",
+ default: DEFAULTS.element.transform.scaleX,
+ min: MIN_TRANSFORM_SCALE,
+ step: 0.01,
+ },
+ {
+ key: "transform.scaleY",
+ label: "Scale Y",
+ type: "number",
+ default: DEFAULTS.element.transform.scaleY,
+ min: MIN_TRANSFORM_SCALE,
+ step: 0.01,
+ },
+ {
+ key: "transform.rotate",
+ label: "Rotate",
+ type: "number",
+ default: DEFAULTS.element.transform.rotate,
+ min: -360,
+ max: 360,
+ step: 1,
+ },
+ {
+ key: "opacity",
+ label: "Opacity",
+ type: "number",
+ default: DEFAULTS.element.opacity,
+ min: 0,
+ max: 1,
+ step: 0.01,
+ },
+ {
+ key: "blendMode",
+ label: "Blend Mode",
+ type: "select",
+ default: DEFAULTS.element.blendMode,
+ keyframable: false,
+ options: BLEND_MODE_OPTIONS,
+ },
+];
+
+const audioElementParams: ElementParamDefinition[] = [
+ {
+ key: "volume",
+ label: "Volume",
+ type: "number",
+ default: DEFAULTS.element.volume,
+ min: VOLUME_DB_MIN,
+ max: VOLUME_DB_MAX,
+ step: 0.01,
+ },
+ {
+ key: "muted",
+ label: "Muted",
+ type: "boolean",
+ default: false,
+ keyframable: false,
+ },
+];
+
+const textElementParams: ElementParamDefinition[] = [
+ {
+ key: "content",
+ label: "Content",
+ type: "text",
+ default: "Default text",
+ keyframable: false,
+ },
+ {
+ key: "fontFamily",
+ label: "Font Family",
+ type: "font",
+ default: "Arial",
+ keyframable: false,
+ },
+ {
+ key: "fontSize",
+ label: "Font Size",
+ type: "number",
+ default: 15,
+ min: 1,
+ step: 1,
+ },
+ {
+ key: "color",
+ label: "Color",
+ type: "color",
+ default: "#ffffff",
+ },
+ {
+ key: "textAlign",
+ label: "Text Align",
+ type: "select",
+ default: "center",
+ keyframable: false,
+ options: [
+ { value: "left", label: "Left" },
+ { value: "center", label: "Center" },
+ { value: "right", label: "Right" },
+ ],
+ },
+ {
+ key: "fontWeight",
+ label: "Font Weight",
+ type: "select",
+ default: "normal",
+ keyframable: false,
+ options: [
+ { value: "normal", label: "Normal" },
+ { value: "bold", label: "Bold" },
+ ],
+ },
+ {
+ key: "fontStyle",
+ label: "Font Style",
+ type: "select",
+ default: "normal",
+ keyframable: false,
+ options: [
+ { value: "normal", label: "Normal" },
+ { value: "italic", label: "Italic" },
+ ],
+ },
+ {
+ key: "textDecoration",
+ label: "Text Decoration",
+ type: "select",
+ default: "none",
+ keyframable: false,
+ options: [
+ { value: "none", label: "None" },
+ { value: "underline", label: "Underline" },
+ { value: "line-through", label: "Line Through" },
+ ],
+ },
+ {
+ key: "letterSpacing",
+ label: "Letter Spacing",
+ type: "number",
+ default: DEFAULTS.text.letterSpacing,
+ min: -100,
+ step: 0.1,
+ },
+ {
+ key: "lineHeight",
+ label: "Line Height",
+ type: "number",
+ default: DEFAULTS.text.lineHeight,
+ min: 0.1,
+ step: 0.1,
+ },
+ {
+ key: "background.enabled",
+ label: "Background Enabled",
+ type: "boolean",
+ default: DEFAULTS.text.background.enabled,
+ keyframable: false,
+ },
+ {
+ key: "background.color",
+ label: "Background Color",
+ type: "color",
+ default: DEFAULTS.text.background.color,
+ dependencies: [{ param: "background.enabled", equals: true }],
+ },
+ {
+ key: "background.cornerRadius",
+ label: "Background Radius",
+ type: "number",
+ default: DEFAULTS.text.background.cornerRadius,
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ step: 1,
+ dependencies: [{ param: "background.enabled", equals: true }],
+ },
+ {
+ key: "background.paddingX",
+ label: "Background Padding X",
+ type: "number",
+ default: DEFAULTS.text.background.paddingX,
+ min: 0,
+ step: 1,
+ dependencies: [{ param: "background.enabled", equals: true }],
+ },
+ {
+ key: "background.paddingY",
+ label: "Background Padding Y",
+ type: "number",
+ default: DEFAULTS.text.background.paddingY,
+ min: 0,
+ step: 1,
+ dependencies: [{ param: "background.enabled", equals: true }],
+ },
+ {
+ key: "background.offsetX",
+ label: "Background Offset X",
+ type: "number",
+ default: DEFAULTS.text.background.offsetX,
+ min: -100_000,
+ step: 1,
+ dependencies: [{ param: "background.enabled", equals: true }],
+ },
+ {
+ key: "background.offsetY",
+ label: "Background Offset Y",
+ type: "number",
+ default: DEFAULTS.text.background.offsetY,
+ min: -100_000,
+ step: 1,
+ dependencies: [{ param: "background.enabled", equals: true }],
+ },
+];
+
+export const elementParamRegistry = new DefinitionRegistry<
+ ElementType,
+ readonly ElementParamDefinition[]
+>("element params");
+
+elementParamRegistry.register({
+ key: "video",
+ definition: [...visualElementParams, ...audioElementParams],
+});
+elementParamRegistry.register({ key: "image", definition: visualElementParams });
+elementParamRegistry.register({
+ key: "text",
+ definition: [...textElementParams, ...visualElementParams],
+});
+elementParamRegistry.register({
+ key: "sticker",
+ definition: visualElementParams,
+});
+elementParamRegistry.register({
+ key: "graphic",
+ definition: visualElementParams,
+});
+elementParamRegistry.register({ key: "audio", definition: audioElementParams });
+elementParamRegistry.register({ key: "effect", definition: [] });
+
+export function getElementParams({
+ element,
+}: {
+ element: TimelineElement;
+}): readonly ElementParamDefinition[] {
+ return elementParamRegistry.has(element.type)
+ ? elementParamRegistry.get(element.type)
+ : [];
+}
+
+export function getBuiltInElementParams({
+ type,
+}: {
+ type: ElementType;
+}): readonly ElementParamDefinition[] {
+ return elementParamRegistry.has(type) ? elementParamRegistry.get(type) : [];
+}
+
+export function getElementParam({
+ element,
+ key,
+}: {
+ element: TimelineElement;
+ key: string;
+}): ElementParamDefinition | null {
+ return (
+ getElementParams({ element }).find((param) => param.key === key) ?? null
+ );
+}
+
+export function readElementParamValue({
+ element,
+ param,
+}: {
+ element: TimelineElement;
+ param: ElementParamDefinition;
+}): ParamValue | null {
+ if (param.read) {
+ return param.read({ element });
+ }
+ if ("params" in element) {
+ return element.params[param.key] ?? param.default;
+ }
+ return null;
+}
+
+export function writeElementParamValue({
+ element,
+ param,
+ value,
+}: {
+ element: TimelineElement;
+ param: ElementParamDefinition;
+ value: ParamValue;
+}): TimelineElement {
+ if (param.write) {
+ return param.write({ element, value });
+ }
+ if ("params" in element) {
+ return {
+ ...element,
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ };
+ }
+ return element;
+}
+
+export function buildElementParamValues({
+ element,
+}: {
+ element: TimelineElement;
+}): ParamValues {
+ const values: ParamValues = {};
+ for (const param of getElementParams({ element })) {
+ const value = readElementParamValue({ element, param });
+ if (value !== null) {
+ values[param.key] = value;
+ }
+ }
+ return values;
+}
+
diff --git a/apps/web/src/preview/components/context-menu.tsx b/apps/web/src/preview/components/context-menu.tsx
index fca55d71..a0f1bc5c 100644
--- a/apps/web/src/preview/components/context-menu.tsx
+++ b/apps/web/src/preview/components/context-menu.tsx
@@ -13,12 +13,12 @@ import { toast } from "sonner";
export function PreviewContextMenu({
onToggleFullscreen,
- containerRef,
+ container,
overlayControls,
onOverlayVisibilityChange,
}: {
onToggleFullscreen: () => void;
- containerRef: React.RefObject<HTMLElement | null>;
+ container: HTMLElement | null;
overlayControls: PreviewOverlayControl[];
onOverlayVisibilityChange: (params: {
overlayId: string;
@@ -51,7 +51,7 @@ export function PreviewContextMenu({
};
return (
- <ContextMenuContent className="w-56" container={containerRef.current}>
+ <ContextMenuContent className="w-56" container={container}>
<ContextMenuItem onClick={viewport.fitToScreen} inset>
Fit to screen
</ContextMenuItem>
diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx
index e8f5c572..894eec78 100644
--- a/apps/web/src/preview/components/index.tsx
+++ b/apps/web/src/preview/components/index.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useMemo, useRef } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/editor/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop";
@@ -68,15 +68,20 @@ export function PreviewPanel({
}) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
+ const [container, setContainer] = useState<HTMLDivElement | null>(null);
const { toggleFullscreen } = useFullscreen({ containerRef });
+ const handleContainerRef = useCallback((node: HTMLDivElement | null) => {
+ containerRef.current = node;
+ setContainer(node);
+ }, []);
return (
<div
- ref={containerRef}
+ ref={handleContainerRef}
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
>
<PreviewCanvas
- containerRef={containerRef}
+ container={container}
onToggleFullscreen={toggleFullscreen}
overlayControls={overlayControls}
overlayInstances={overlayInstances}
@@ -117,13 +122,13 @@ function RenderTreeController() {
}
function PreviewCanvas({
- containerRef,
+ container,
onToggleFullscreen,
overlayControls,
overlayInstances,
onOverlayVisibilityChange,
}: {
- containerRef: React.RefObject<HTMLElement | null>;
+ container: HTMLElement | null;
onToggleFullscreen: () => void;
overlayControls: PreviewOverlayControl[];
overlayInstances: PreviewOverlayInstance[];
@@ -149,6 +154,7 @@ function PreviewCanvas({
viewportRef,
viewportWidth: viewportSize.width,
});
+ const { canPan, panByScreenDelta, scaleZoom } = viewport;
const renderer = useMemo(() => {
return new CanvasRenderer({
@@ -240,7 +246,7 @@ function PreviewCanvas({
Math.min(Math.abs(pendingZoomDelta), 30);
const zoomFactor = Math.exp(-cappedDelta / 300);
- viewport.scaleZoom({ factor: zoomFactor });
+ scaleZoom({ factor: zoomFactor });
pendingZoomDelta = 0;
zoomRafId = null;
});
@@ -249,7 +255,7 @@ function PreviewCanvas({
return;
}
- if (!viewport.canPan) {
+ if (!canPan) {
return;
}
@@ -263,7 +269,7 @@ function PreviewCanvas({
if (panRafId === null) {
panRafId = requestAnimationFrame(() => {
- viewport.panByScreenDelta({
+ panByScreenDelta({
deltaX: pendingPanDeltaX,
deltaY: pendingPanDeltaY,
});
@@ -290,7 +296,7 @@ function PreviewCanvas({
cancelAnimationFrame(panRafId);
}
};
- }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]);
+ }, [canPan, panByScreenDelta, scaleZoom]);
return (
<PreviewViewportProvider value={viewport}>
@@ -329,7 +335,7 @@ function PreviewCanvas({
</ContextMenuTrigger>
<PreviewContextMenu
onToggleFullscreen={onToggleFullscreen}
- containerRef={containerRef}
+ container={container}
overlayControls={overlayControls}
onOverlayVisibilityChange={onOverlayVisibilityChange}
/>
diff --git a/apps/web/src/preview/components/mask-handles.tsx b/apps/web/src/preview/components/mask-handles.tsx
index 340cab0a..314984ef 100644
--- a/apps/web/src/preview/components/mask-handles.tsx
+++ b/apps/web/src/preview/components/mask-handles.tsx
@@ -3,6 +3,7 @@
import { PEN_CURSOR } from "@/preview/components/cursors";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import { useMaskHandles } from "@/masks/use-mask-handles";
+import { maskHandleIdKey, type MaskHandleId } from "@/masks/types";
import type { SnapLine } from "@/preview/preview-snap";
import {
CornerHandle,
@@ -16,7 +17,6 @@ import {
} from "./handle-primitives";
const CUSTOM_MASK_ANCHOR_SIZE = 7;
-const CUSTOM_MASK_TANGENT_SIZE = 6;
import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons";
export function MaskHandles({
@@ -29,7 +29,7 @@ export function MaskHandles({
selectedWithMask,
handlePositions,
overlays,
- isCreatingCustomMask,
+ isCreatingFreeformPathMask,
handleCanvasPointerDown,
handlePointerDown,
handlePointerMove,
@@ -72,7 +72,7 @@ export function MaskHandles({
handleId,
}: {
event: React.PointerEvent;
- handleId: string;
+ handleId: MaskHandleId;
}) => {
if (viewport.handlePanPointerDown({ event })) {
return;
@@ -93,7 +93,7 @@ export function MaskHandles({
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
- {isCreatingCustomMask ? (
+ {isCreatingFreeformPathMask ? (
<div
className="absolute inset-0 pointer-events-auto"
style={{ cursor: PEN_CURSOR }}
@@ -193,11 +193,12 @@ export function MaskHandles({
})}
{handlePositions.map((handle) => {
const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y });
+ const key = maskHandleIdKey({ id: handle.id });
if (handle.kind === "icon" && handle.icon === "rotate") {
return (
<IconHandle
- key={handle.id}
+ key={key}
icon={Rotate01Icon}
screen={screen}
onPointerDown={(event) =>
@@ -212,7 +213,7 @@ export function MaskHandles({
if (handle.kind === "icon" && handle.icon === "feather") {
return (
<IconHandle
- key={handle.id}
+ key={key}
icon={FeatherIcon}
screen={screen}
onPointerDown={(event) =>
@@ -227,7 +228,7 @@ export function MaskHandles({
if (handle.kind === "edge" && handle.edgeAxis === "horizontal") {
return (
<EdgeHandle
- key={handle.id}
+ key={key}
edge="right"
screen={screen}
rotation={handle.rotation ?? 0}
@@ -243,7 +244,7 @@ export function MaskHandles({
if (handle.kind === "edge" && handle.edgeAxis === "vertical") {
return (
<EdgeHandle
- key={handle.id}
+ key={key}
edge="bottom"
screen={screen}
rotation={handle.rotation ?? 0}
@@ -256,30 +257,26 @@ export function MaskHandles({
);
}
- if (handle.kind === "point" || handle.kind === "tangent") {
- return (
- <CircleHandle
- key={handle.id}
- screen={screen}
- size={
- handle.kind === "tangent"
- ? CUSTOM_MASK_TANGENT_SIZE
- : CUSTOM_MASK_ANCHOR_SIZE
- }
- isSelected={handle.isSelected}
- onPointerDown={(event) =>
- handleMaskPointerDown({ event, handleId: handle.id })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- }
+ if (handle.kind === "point") {
+ return (
+ <CircleHandle
+ key={key}
+ screen={screen}
+ size={CUSTOM_MASK_ANCHOR_SIZE}
+ isSelected={handle.isSelected}
+ onPointerDown={(event) =>
+ handleMaskPointerDown({ event, handleId: handle.id })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ }
if (handle.kind === "corner") {
return (
<CornerHandle
- key={handle.id}
+ key={key}
screen={screen}
onPointerDown={(event) =>
handleMaskPointerDown({ event, handleId: handle.id })
@@ -292,7 +289,7 @@ export function MaskHandles({
return (
<CornerHandle
- key={handle.id}
+ key={key}
cursor={handle.cursor}
screen={screen}
onPointerDown={(event) =>
diff --git a/apps/web/src/preview/components/preview-viewport.tsx b/apps/web/src/preview/components/preview-viewport.tsx
index 6dc9f084..be261adc 100644
--- a/apps/web/src/preview/components/preview-viewport.tsx
+++ b/apps/web/src/preview/components/preview-viewport.tsx
@@ -9,6 +9,7 @@ import {
useRef,
useState,
} from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import {
canvasToOverlay,
getDisplayScale,
@@ -219,8 +220,7 @@ export function usePreviewViewportState({
}));
const [isPanning, setIsPanning] = useState(false);
const panSessionRef = useRef<PanSession | null>(null);
- const centerRef = useRef(center);
- centerRef.current = center;
+ const centerRef = useCommittedRef(center);
const fitScale = useMemo(
() =>
@@ -409,10 +409,10 @@ export function usePreviewViewportState({
pointerId: event.pointerId,
};
setIsPanning(true);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ event.currentTarget.setPointerCapture(event.pointerId);
return true;
},
- [zoom],
+ [centerRef, zoom],
);
const handlePanPointerMove = useCallback(
@@ -451,13 +451,9 @@ export function usePreviewViewportState({
}
if (
- (event.currentTarget as HTMLElement).hasPointerCapture(
- panSession.pointerId,
- )
+ event.currentTarget.hasPointerCapture(panSession.pointerId)
) {
- (event.currentTarget as HTMLElement).releasePointerCapture(
- panSession.pointerId,
- );
+ event.currentTarget.releasePointerCapture(panSession.pointerId);
}
panSessionRef.current = null;
diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx
index 1d599b3b..c06d50ed 100644
--- a/apps/web/src/preview/components/text-edit-overlay.tsx
+++ b/apps/web/src/preview/components/text-edit-overlay.tsx
@@ -9,7 +9,12 @@ import {
getElementLocalTime,
} from "@/animation";
import { resolveTransformAtTime } from "@/rendering/animation-values";
+import { buildTransformFromParams } from "@/rendering";
import { resolveTextLayout } from "@/text/primitives";
+import {
+ buildTextBackgroundFromElement,
+ buildTextLayoutParamsFromElement,
+} from "@/text/measure-element";
export function TextEditOverlay({
trackId,
@@ -42,7 +47,7 @@ export function TextEditOverlay({
if (!div) return;
const text = div.innerText;
editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: { content: text } }],
+ updates: [{ trackId, elementId, updates: { params: { content: text } } }],
});
}, [editor.timeline, trackId, elementId]);
@@ -69,7 +74,7 @@ export function TextEditOverlay({
elementDuration: element.duration,
});
const transform = resolveTransformAtTime({
- baseTransform: element.transform,
+ baseTransform: buildTransformFromParams({ params: element.params }),
animations: element.animations,
localTime,
});
@@ -80,26 +85,17 @@ export function TextEditOverlay({
});
const { x: displayScaleX } = viewport.getDisplayScale();
+ const textParams = buildTextLayoutParamsFromElement({ element });
const resolvedTextLayout = resolveTextLayout({
- text: {
- content: element.content,
- fontSize: element.fontSize,
- fontFamily: element.fontFamily,
- fontWeight: element.fontWeight,
- fontStyle: element.fontStyle,
- textAlign: element.textAlign,
- textDecoration: element.textDecoration,
- letterSpacing: element.letterSpacing,
- lineHeight: element.lineHeight,
- },
+ text: textParams,
canvasHeight: canvasSize.height,
});
- const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
- const canvasLetterSpacing = element.letterSpacing ?? 0;
+ const lineHeight = textParams.lineHeight ?? DEFAULTS.text.lineHeight;
+ const canvasLetterSpacing = textParams.letterSpacing ?? 0;
const lineHeightPx = resolvedTextLayout.lineHeightPx;
- const bg = element.background;
+ const bg = buildTextBackgroundFromElement({ element });
const shouldShowBackground =
bg.enabled && bg.color && bg.color !== "transparent";
const fontSizeRatio = resolvedTextLayout.fontSizeRatio;
@@ -120,7 +116,6 @@ export function TextEditOverlay({
transformOrigin: "center center",
}}
>
- {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
<div
ref={divRef}
contentEditable
@@ -131,17 +126,20 @@ export function TextEditOverlay({
className="cursor-text select-text outline-none whitespace-pre"
style={{
fontSize: resolvedTextLayout.scaledFontSize,
- fontFamily: element.fontFamily,
- fontWeight: element.fontWeight === "bold" ? "bold" : "normal",
- fontStyle: element.fontStyle === "italic" ? "italic" : "normal",
- textAlign: element.textAlign,
+ fontFamily: textParams.fontFamily,
+ fontWeight: textParams.fontWeight === "bold" ? "bold" : "normal",
+ fontStyle: textParams.fontStyle === "italic" ? "italic" : "normal",
+ textAlign: textParams.textAlign,
letterSpacing: `${canvasLetterSpacing}px`,
lineHeight,
color: "transparent",
- caretColor: element.color,
+ caretColor:
+ typeof element.params.color === "string"
+ ? element.params.color
+ : "#ffffff",
backgroundColor: shouldShowBackground ? bg.color : "transparent",
minHeight: lineHeightPx,
- textDecoration: element.textDecoration ?? "none",
+ textDecoration: textParams.textDecoration ?? "none",
padding: shouldShowBackground
? `${canvasPaddingY}px ${canvasPaddingX}px`
: 0,
@@ -151,7 +149,7 @@ export function TextEditOverlay({
onBlur={onCommit}
onKeyDown={(event) => handleKeyDown({ event })}
>
- {element.content || ""}
+ {textParams.content}
</div>
</div>
);
diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx
index 783dd239..1a5ce1bc 100644
--- a/apps/web/src/preview/components/toolbar.tsx
+++ b/apps/web/src/preview/components/toolbar.tsx
@@ -22,6 +22,9 @@ import {
} from "@/components/ui/select";
import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
import { usePreviewViewport } from "./preview-viewport";
+import { GridPopover } from "./guide-popover";
+import { usePreviewStore } from "@/preview/preview-store";
+import type { MediaTime } from "@/wasm";
export function PreviewToolbar({
onToggleFullscreen,
@@ -60,7 +63,7 @@ function TimecodeDisplay() {
const editor = useEditor();
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
const fps = useEditor((e) => e.project.getActive().settings.fps);
- const [currentTime, setCurrentTime] = useState(() =>
+ const [currentTime, setCurrentTime] = useState<MediaTime>(() =>
editor.playback.getCurrentTime(),
);
diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts
index 00573662..118b48ae 100644
--- a/apps/web/src/preview/controllers/preview-interaction-controller.ts
+++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts
@@ -18,7 +18,8 @@ import {
type SnapLine,
} from "@/preview/preview-snap";
import type { TCanvasSize } from "@/project/types";
-import type { Transform } from "@/rendering";
+import type { ParamValues } from "@/params";
+import { buildTransformFromParams, type Transform } from "@/rendering";
import { isVisualElement } from "@/timeline/element-utils";
import type {
ElementRef,
@@ -51,6 +52,7 @@ interface DragElementSnapshot {
readonly trackId: string;
readonly elementId: string;
readonly initialTransform: Transform;
+ readonly initialParams: ParamValues;
}
interface DraggingGesture extends CapturedPointerState {
@@ -218,7 +220,8 @@ function toDragElementSnapshots({
.map(({ track, element }) => ({
trackId: track.id,
elementId: element.id,
- initialTransform: element.transform,
+ initialTransform: buildTransformFromParams({ params: element.params }),
+ initialParams: element.params,
}));
}
@@ -565,16 +568,14 @@ export class PreviewInteractionController {
snappedPosition.y - firstElement.initialTransform.position.y;
this.deps.timeline.previewElements(
- drag.elements.map(({ trackId, elementId, initialTransform }) => ({
+ drag.elements.map(({ trackId, elementId, initialTransform, initialParams }) => ({
trackId,
elementId,
updates: {
- transform: {
- ...initialTransform,
- position: {
- x: initialTransform.position.x + deltaSnappedX,
- y: initialTransform.position.y + deltaSnappedY,
- },
+ params: {
+ ...initialParams,
+ "transform.positionX": initialTransform.position.x + deltaSnappedX,
+ "transform.positionY": initialTransform.position.y + deltaSnappedY,
},
},
})),
diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts
index 67f677f6..d9b5ef1f 100644
--- a/apps/web/src/preview/controllers/transform-handle-controller.ts
+++ b/apps/web/src/preview/controllers/transform-handle-controller.ts
@@ -23,7 +23,8 @@ import {
setChannel,
} from "@/animation";
import type { ElementAnimations } from "@/animation/types";
-import type { Transform } from "@/rendering";
+import type { ParamValues } from "@/params";
+import { buildTransformFromParams, type Transform } from "@/rendering";
import { resolveTransformAtTime } from "@/rendering/animation-values";
import type {
ElementRef,
@@ -47,6 +48,7 @@ interface CornerScaleSession extends CapturedPointerState {
readonly trackId: string;
readonly elementId: string;
readonly initialTransform: Transform;
+ readonly initialParams: ParamValues;
readonly initialDistance: number;
readonly initialBoundsCx: number;
readonly initialBoundsCy: number;
@@ -62,6 +64,7 @@ interface EdgeScaleSession extends CapturedPointerState {
readonly trackId: string;
readonly elementId: string;
readonly initialTransform: Transform;
+ readonly initialParams: ParamValues;
readonly initialBoundsCx: number;
readonly initialBoundsCy: number;
readonly baseWidth: number;
@@ -76,6 +79,7 @@ interface RotationSession extends CapturedPointerState {
readonly trackId: string;
readonly elementId: string;
readonly initialTransform: Transform;
+ readonly initialParams: ParamValues;
readonly initialAngle: number;
readonly initialBoundsCx: number;
readonly initialBoundsCy: number;
@@ -369,6 +373,7 @@ export class TransformHandleController {
trackId: context.trackId,
elementId: context.elementId,
initialTransform: context.resolvedTransform,
+ initialParams: context.element.params,
initialDistance: getCornerDistance({
bounds: context.bounds,
corner,
@@ -410,6 +415,7 @@ export class TransformHandleController {
trackId: context.trackId,
elementId: context.elementId,
initialTransform: context.resolvedTransform,
+ initialParams: context.element.params,
initialAngle,
initialBoundsCx: context.bounds.cx,
initialBoundsCy: context.bounds.cy,
@@ -447,6 +453,7 @@ export class TransformHandleController {
trackId: context.trackId,
elementId: context.elementId,
initialTransform: context.resolvedTransform,
+ initialParams: context.element.params,
initialBoundsCx: context.bounds.cx,
initialBoundsCy: context.bounds.cy,
baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
@@ -561,7 +568,9 @@ export class TransformHandleController {
element: selectedWithBounds.element,
bounds: selectedWithBounds.bounds,
resolvedTransform: resolveTransformAtTime({
- baseTransform: selectedWithBounds.element.transform,
+ baseTransform: buildTransformFromParams({
+ params: selectedWithBounds.element.params,
+ }),
animations: selectedWithBounds.element.animations,
localTime,
}),
@@ -608,15 +617,18 @@ export class TransformHandleController {
trackId: session.trackId,
elementId: session.elementId,
updates: {
- transform: {
- ...session.initialTransform,
- scaleX: clampScaleNonZero(
- session.initialTransform.scaleX * snappedScale,
- ),
- scaleY: clampScaleNonZero(
- session.initialTransform.scaleY * snappedScale,
- ),
- },
+ params: buildParamsWithTransform({
+ params: session.initialParams,
+ transform: {
+ ...session.initialTransform,
+ scaleX: clampScaleNonZero(
+ session.initialTransform.scaleX * snappedScale,
+ ),
+ scaleY: clampScaleNonZero(
+ session.initialTransform.scaleY * snappedScale,
+ ),
+ },
+ }),
...(session.shouldClearScaleAnimation && {
animations: session.animationsWithoutScale,
}),
@@ -699,17 +711,20 @@ export class TransformHandleController {
trackId: session.trackId,
elementId: session.elementId,
updates: {
- transform: {
- ...session.initialTransform,
- scaleX:
- session.edge === "right" || session.edge === "left"
- ? xSnap.snappedScale
- : session.initialTransform.scaleX,
- scaleY:
- session.edge === "bottom"
- ? ySnap.snappedScale
- : session.initialTransform.scaleY,
- },
+ params: buildParamsWithTransform({
+ params: session.initialParams,
+ transform: {
+ ...session.initialTransform,
+ scaleX:
+ session.edge === "right" || session.edge === "left"
+ ? xSnap.snappedScale
+ : session.initialTransform.scaleX,
+ scaleY:
+ session.edge === "bottom"
+ ? ySnap.snappedScale
+ : session.initialTransform.scaleY,
+ },
+ }),
...(session.shouldClearScaleAnimation && {
animations: session.animationsWithoutScale,
}),
@@ -742,12 +757,32 @@ export class TransformHandleController {
trackId: session.trackId,
elementId: session.elementId,
updates: {
- transform: {
- ...session.initialTransform,
- rotate: snappedRotation,
- },
+ params: buildParamsWithTransform({
+ params: session.initialParams,
+ transform: {
+ ...session.initialTransform,
+ rotate: snappedRotation,
+ },
+ }),
},
},
]);
}
}
+
+function buildParamsWithTransform({
+ params,
+ transform,
+}: {
+ params: ParamValues;
+ transform: Transform;
+}): ParamValues {
+ return {
+ ...params,
+ "transform.positionX": transform.position.x,
+ "transform.positionY": transform.position.y,
+ "transform.scaleX": transform.scaleX,
+ "transform.scaleY": transform.scaleY,
+ "transform.rotate": transform.rotate,
+ };
+}
diff --git a/apps/web/src/preview/element-bounds.ts b/apps/web/src/preview/element-bounds.ts
index 0afc6e4e..181fdf1f 100644
--- a/apps/web/src/preview/element-bounds.ts
+++ b/apps/web/src/preview/element-bounds.ts
@@ -7,6 +7,7 @@ import {
getElementLocalTime,
} from "@/animation";
import { resolveTransformAtTime } from "@/rendering/animation-values";
+import { buildTransformFromParams } from "@/rendering";
export interface ElementBounds {
cx: number;
@@ -123,7 +124,7 @@ function getElementBounds({
if (element.type === "video" || element.type === "image") {
const transform = resolveTransformAtTime({
- baseTransform: element.transform,
+ baseTransform: buildTransformFromParams({ params: element.params }),
animations: element.animations,
localTime,
});
@@ -140,7 +141,7 @@ function getElementBounds({
if (element.type === "sticker") {
const transform = resolveTransformAtTime({
- baseTransform: element.transform,
+ baseTransform: buildTransformFromParams({ params: element.params }),
animations: element.animations,
localTime,
});
@@ -155,7 +156,7 @@ function getElementBounds({
if (element.type === "graphic") {
const transform = resolveTransformAtTime({
- baseTransform: element.transform,
+ baseTransform: buildTransformFromParams({ params: element.params }),
animations: element.animations,
localTime,
});
@@ -170,7 +171,7 @@ function getElementBounds({
if (element.type === "text") {
const transform = resolveTransformAtTime({
- baseTransform: element.transform,
+ baseTransform: buildTransformFromParams({ params: element.params }),
animations: element.animations,
localTime,
});
diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts
index d093dcf2..b707f1e6 100644
--- a/apps/web/src/preview/hooks/use-preview-interaction.ts
+++ b/apps/web/src/preview/hooks/use-preview-interaction.ts
@@ -1,5 +1,6 @@
-import { useEffect, useReducer, useRef } from "react";
+import { useEffect, useReducer, useState } from "react";
import { useEditor } from "@/editor/use-editor";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useShiftKey } from "@/hooks/use-shift-key";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import type { SnapLine } from "@/preview/preview-snap";
@@ -59,17 +60,10 @@ export function usePreviewInteraction({
onSnapLinesChange,
},
};
-
- const depsRef = useRef<PreviewInteractionDeps>(deps);
- depsRef.current = deps;
-
- const controllerRef = useRef<PreviewInteractionController | null>(null);
- if (!controllerRef.current) {
- controllerRef.current = new PreviewInteractionController({
- depsRef: depsRef as PreviewInteractionDepsRef,
- });
- }
- const controller = controllerRef.current;
+ const depsRef = useCommittedRef(deps) as PreviewInteractionDepsRef;
+ const [controller] = useState(
+ () => new PreviewInteractionController({ depsRef }),
+ );
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(
diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts
index f4b7e306..55d8d8f7 100644
--- a/apps/web/src/preview/hooks/use-transform-handles.ts
+++ b/apps/web/src/preview/hooks/use-transform-handles.ts
@@ -1,7 +1,8 @@
-import { useEffect, useReducer, useRef } from "react";
+import { useEffect, useReducer, useState } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
import { useEditor } from "@/editor/use-editor";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useShiftKey } from "@/hooks/use-shift-key";
import { registerCanceller } from "@/editor/cancel-interaction";
import {
@@ -48,15 +49,10 @@ export function useTransformHandles({
onSnapLinesChange,
},
};
-
- const depsRef = useRef<TransformHandleDeps>(deps);
- depsRef.current = deps;
-
- const controllerRef = useRef<TransformHandleController | null>(null);
- if (!controllerRef.current) {
- controllerRef.current = new TransformHandleController({ depsRef });
- }
- const controller = controllerRef.current;
+ const depsRef = useCommittedRef(deps);
+ const [controller] = useState(
+ () => new TransformHandleController({ depsRef }),
+ );
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => controller.subscribe(rerender), [controller]);
diff --git a/apps/web/src/project/types.ts b/apps/web/src/project/types.ts
index 42e844f6..dca2d785 100644
--- a/apps/web/src/project/types.ts
+++ b/apps/web/src/project/types.ts
@@ -1,5 +1,6 @@
import type { FrameRate } from "opencut-wasm";
import type { TScene } from "@/timeline/types";
+import type { MediaTime } from "@/wasm";
export type TBackground =
| {
@@ -20,7 +21,7 @@ export interface TProjectMetadata {
id: string;
name: string;
thumbnail?: string;
- duration: number;
+ duration: MediaTime;
createdAt: Date;
updatedAt: Date;
}
@@ -37,7 +38,7 @@ export interface TProjectSettings {
export interface TTimelineViewState {
zoomLevel: number;
scrollLeft: number;
- playheadTime: number;
+ playheadTime: MediaTime;
}
export interface TProject {
diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx
deleted file mode 100644
index 48b1d0e5..00000000
--- a/apps/web/src/rendering/components/blending-tab.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import { useEditor } from "@/editor/use-editor";
-import { clamp } from "@/utils/math";
-import { NumberField } from "@/components/ui/number-field";
-import { OcCheckerboardIcon } from "@/components/icons";
-import { Fragment, useRef } from "react";
-import { useMenuPreview } from "@/editor/use-menu-preview";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import type { BlendMode } from "@/rendering";
-import type { ElementType } from "@/timeline";
-import type { ElementAnimations } from "@/animation/types";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { RainDropIcon } from "@hugeicons/core-free-icons";
-import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
-import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import { resolveOpacityAtTime } from "@/animation/values";
-import { DEFAULTS } from "@/timeline/defaults";
-import { isPropertyAtDefault } from "./transform-tab";
-
-type BlendingElement = {
- id: string;
- opacity: number;
- type: ElementType;
- blendMode?: BlendMode;
- startTime: number;
- duration: number;
- animations?: ElementAnimations;
-};
-
-const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [
- [{ value: "normal", label: "Normal" }],
- [
- { value: "darken", label: "Darken" },
- { value: "multiply", label: "Multiply" },
- { value: "color-burn", label: "Color Burn" },
- ],
- [
- { value: "lighten", label: "Lighten" },
- { value: "screen", label: "Screen" },
- { value: "plus-lighter", label: "Plus Lighter" },
- { value: "color-dodge", label: "Color Dodge" },
- ],
- [
- { value: "overlay", label: "Overlay" },
- { value: "soft-light", label: "Soft Light" },
- { value: "hard-light", label: "Hard Light" },
- ],
- [
- { value: "difference", label: "Difference" },
- { value: "exclusion", label: "Exclusion" },
- ],
- [
- { value: "hue", label: "Hue" },
- { value: "saturation", label: "Saturation" },
- { value: "color", label: "Color" },
- { value: "luminosity", label: "Luminosity" },
- ],
-];
-
-export function BlendingTab({
- element,
- trackId,
-}: {
- element: BlendingElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
- const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
- const committedBlendModeRef = useRef(blendMode);
- if (!isPreviewActive) {
- committedBlendModeRef.current = blendMode;
- }
-
- const {
- onPointerLeave,
- onOpenChange: handleBlendModeOpenChange,
- markCommitted,
- } = useMenuPreview();
-
- const previewBlendMode = ({ value }: { value: BlendMode }) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { blendMode: value } },
- ],
- });
-
- const commitBlendMode = (value: BlendMode) => {
- if (editor.timeline.isPreviewActive()) {
- editor.timeline.commitPreview();
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { blendMode: value },
- },
- ],
- });
- }
- markCommitted();
- };
-
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedOpacity = resolveOpacityAtTime({
- baseOpacity: element.opacity,
- animations: element.animations,
- localTime,
- });
-
- const opacity = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "opacity",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOpacity * 100).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({ value: parsed, min: 0, max: 100 }) / 100;
- },
- valueAtPlayhead: resolvedOpacity,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({ opacity: value }),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:blending`}>
- <SectionHeader>
- <SectionTitle>Blending</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField
- label="Opacity"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={opacity.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle opacity keyframe"
- onToggle={opacity.toggleKeyframe}
- />
- }
- >
- <NumberField
- className="w-full"
- icon={
- <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
- }
- value={opacity.displayValue}
- min={0}
- max={100}
- onFocus={opacity.onFocus}
- onChange={opacity.onChange}
- onBlur={opacity.onBlur}
- onScrub={opacity.scrubTo}
- onScrubEnd={opacity.commitScrub}
- onReset={() =>
- opacity.commitValue({ value: DEFAULTS.element.opacity })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOpacity,
- staticValue: element.opacity,
- defaultValue: DEFAULTS.element.opacity,
- })}
- dragSensitivity="slow"
- />
- </SectionField>
- <SectionField label="Blend mode" className="w-1/2">
- <Select
- value={committedBlendModeRef.current}
- onOpenChange={handleBlendModeOpenChange}
- onValueChange={commitBlendMode}
- >
- <SelectTrigger
- icon={<HugeiconsIcon icon={RainDropIcon} />}
- className="w-full"
- >
- <SelectValue placeholder="Select blend mode" />
- </SelectTrigger>
- <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
- {BLEND_MODE_GROUPS.map((group, groupIndex) => (
- <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
- {group.map((option) => (
- <SelectItem
- key={option.value}
- value={option.value}
- onPointerEnter={() =>
- previewBlendMode({ value: option.value })
- }
- >
- {option.label}
- </SelectItem>
- ))}
- {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
- <SelectSeparator />
- ) : null}
- </Fragment>
- ))}
- </SelectContent>
- </Select>
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/rendering/components/transform-tab.tsx b/apps/web/src/rendering/components/transform-tab.tsx
deleted file mode 100644
index f4d5a183..00000000
--- a/apps/web/src/rendering/components/transform-tab.tsx
+++ /dev/null
@@ -1,462 +0,0 @@
-import { NumberField } from "@/components/ui/number-field";
-import { useEditor } from "@/editor/use-editor";
-import { clamp, isNearlyEqual } from "@/utils/math";
-import type { VisualElement } from "@/timeline";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- ArrowExpandIcon,
- Link05Icon,
- RotateClockwiseIcon,
-} from "@hugeicons/core-free-icons";
-import {
- getGroupKeyframesAtTime,
- hasGroupKeyframeAtTime,
-} from "@/animation";
-import { resolveTransformAtTime } from "@/rendering/animation-values";
-import { DEFAULTS } from "@/timeline/defaults";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
-import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
-import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
-
-export function parseNumericInput({ input }: { input: string }): number | null {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : parsed;
-}
-
-export function isPropertyAtDefault({
- hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue,
- staticValue,
- defaultValue,
-}: {
- hasAnimatedKeyframes: boolean;
- isPlayheadWithinElementRange: boolean;
- resolvedValue: number;
- staticValue: number;
- defaultValue: number;
-}): boolean {
- if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
- return isNearlyEqual({
- leftValue: resolvedValue,
- rightValue: defaultValue,
- });
- }
-
- return staticValue === defaultValue;
-}
-
-export function TransformTab({
- element,
- trackId,
-}: {
- element: VisualElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
- const setTransformScaleLocked = usePropertiesStore(
- (s) => s.setTransformScaleLocked,
- );
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const positionX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.positionX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.x).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.x,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: { ...element.transform.position, x: value },
- },
- }),
- });
-
- const positionY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.positionY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.position.y).toString(),
- parse: (input) => parseNumericInput({ input }),
- valueAtPlayhead: resolvedTransform.position.y,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- position: { ...element.transform.position, y: value },
- },
- }),
- });
-
- const parseScale = (input: string) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return parsed / 100;
- };
-
- const scaleX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scaleX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
- parse: parseScale,
- valueAtPlayhead: resolvedTransform.scaleX,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scaleX: value,
- ...(isScaleLocked ? { scaleY: value } : {}),
- },
- }),
- buildAdditionalKeyframes: isScaleLocked
- ? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
- : undefined,
- });
-
- const scaleY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.scaleY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
- parse: parseScale,
- valueAtPlayhead: resolvedTransform.scaleY,
- step: 0.01,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- scaleY: value,
- ...(isScaleLocked ? { scaleX: value } : {}),
- },
- }),
- buildAdditionalKeyframes: isScaleLocked
- ? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
- : undefined,
- });
-
- const scaleFieldPropsX = {
- value: scaleX.displayValue,
- onFocus: scaleX.onFocus,
- onChange: scaleX.onChange,
- onBlur: scaleX.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scaleX.scrubTo,
- onScrubEnd: scaleX.commitScrub,
- onReset: () =>
- scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scaleX,
- staticValue: element.transform.scaleX,
- defaultValue: DEFAULTS.element.transform.scaleX,
- }),
- };
-
- const scaleFieldPropsY = {
- value: scaleY.displayValue,
- onFocus: scaleY.onFocus,
- onChange: scaleY.onChange,
- onBlur: scaleY.onBlur,
- dragSensitivity: "slow" as const,
- onScrub: scaleY.scrubTo,
- onScrubEnd: scaleY.commitScrub,
- onReset: () =>
- scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
- isDefault: isPropertyAtDefault({
- hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.scaleY,
- staticValue: element.transform.scaleY,
- defaultValue: DEFAULTS.element.transform.scaleY,
- }),
- };
-
- const rotation = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "transform.rotate",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedTransform.rotate).toString(),
- parse: (input) => {
- const parsed = parseNumericInput({ input });
- if (parsed === null) return null;
- return clamp({ value: parsed, min: -360, max: 360 });
- },
- valueAtPlayhead: resolvedTransform.rotate,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- transform: {
- ...element.transform,
- rotate: value,
- },
- }),
- });
-
- const hasScaleKeyframe = hasGroupKeyframeAtTime({
- animations: element.animations,
- group: "transform.scale",
- time: localTime,
- });
-
- const toggleScaleKeyframe = () => {
- if (!isPlayheadWithinElementRange) return;
- const existing = getGroupKeyframesAtTime({
- animations: element.animations,
- group: "transform.scale",
- time: localTime,
- });
- if (existing.length > 0) {
- editor.timeline.removeKeyframes({
- keyframes: existing.map((ref) => ({
- trackId,
- elementId: element.id,
- ...ref,
- })),
- });
- return;
- }
- editor.timeline.upsertKeyframes({
- keyframes: [
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.scaleX",
- time: localTime,
- value: resolvedTransform.scaleX,
- },
- {
- trackId,
- elementId: element.id,
- propertyPath: "transform.scaleY",
- time: localTime,
- value: resolvedTransform.scaleY,
- },
- ],
- });
- };
-
- const scaleLockButton = (
- <Button
- type="button"
- variant={isScaleLocked ? "secondary" : "ghost"}
- size="icon"
- aria-pressed={isScaleLocked}
- onClick={() => setTransformScaleLocked(!isScaleLocked)}
- >
- <HugeiconsIcon icon={Link05Icon} />
- </Button>
- );
-
- return (
- <Section collapsible sectionKey={`${element.id}:transform`}>
- <SectionHeader>
- <SectionTitle>Transform</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <div className="flex items-end gap-2">
- {isScaleLocked ? (
- <>
- <SectionField
- label="Scale"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={hasScaleKeyframe}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle scale keyframe"
- onToggle={toggleScaleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
- {...scaleFieldPropsX}
- />
- </SectionField>
- {scaleLockButton}
- </>
- ) : (
- <>
- <SectionField
- label="Width"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={scaleX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle width scale keyframe"
- onToggle={scaleX.toggleKeyframe}
- />
- }
- >
- <NumberField icon="W" {...scaleFieldPropsX} />
- </SectionField>
- {scaleLockButton}
- <SectionField
- label="Height"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={scaleY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle height scale keyframe"
- onToggle={scaleY.toggleKeyframe}
- />
- }
- >
- <NumberField icon="H" {...scaleFieldPropsY} />
- </SectionField>
- </>
- )}
- </div>
- <div className="flex items-end gap-2">
- <SectionField
- label="X"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={positionX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle X position keyframe"
- onToggle={positionX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="X"
- value={positionX.displayValue}
- onFocus={positionX.onFocus}
- onChange={positionX.onChange}
- onBlur={positionX.onBlur}
- onScrub={positionX.scrubTo}
- onScrubEnd={positionX.commitScrub}
- onReset={() =>
- positionX.commitValue({
- value: DEFAULTS.element.transform.position.x,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.x,
- staticValue: element.transform.position.x,
- defaultValue: DEFAULTS.element.transform.position.x,
- })}
- />
- </SectionField>
- <SectionField
- label="Y"
- className="min-w-0 flex-1"
- beforeLabel={
- <KeyframeToggle
- isActive={positionY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle Y position keyframe"
- onToggle={positionY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="Y"
- value={positionY.displayValue}
- onFocus={positionY.onFocus}
- onChange={positionY.onChange}
- onBlur={positionY.onBlur}
- onScrub={positionY.scrubTo}
- onScrubEnd={positionY.commitScrub}
- onReset={() =>
- positionY.commitValue({
- value: DEFAULTS.element.transform.position.y,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.position.y,
- staticValue: element.transform.position.y,
- defaultValue: DEFAULTS.element.transform.position.y,
- })}
- />
- </SectionField>
- </div>
-
- <SectionField
- label="Rotation"
- beforeLabel={
- <KeyframeToggle
- isActive={rotation.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle rotation keyframe"
- onToggle={rotation.toggleKeyframe}
- />
- }
- >
- <div className="flex items-center gap-2">
- <NumberField
- icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
- className="flex-none"
- value={rotation.displayValue}
- onFocus={rotation.onFocus}
- onChange={rotation.onChange}
- onBlur={rotation.onBlur}
- dragSensitivity="slow"
- onScrub={rotation.scrubTo}
- onScrubEnd={rotation.commitScrub}
- onReset={() =>
- rotation.commitValue({
- value: DEFAULTS.element.transform.rotate,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedTransform.rotate,
- staticValue: element.transform.rotate,
- defaultValue: DEFAULTS.element.transform.rotate,
- })}
- />
- </div>
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/rendering/index.ts b/apps/web/src/rendering/index.ts
index c5555371..21e66447 100644
--- a/apps/web/src/rendering/index.ts
+++ b/apps/web/src/rendering/index.ts
@@ -1,3 +1,5 @@
+import type { ParamValues } from "@/params";
+
export interface Transform {
scaleX: number;
scaleY: number;
@@ -26,3 +28,71 @@ export type BlendMode =
| "saturation"
| "color"
| "luminosity";
+
+export function buildTransformFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): Transform {
+ return {
+ scaleX: readNumberParam({ params, key: "transform.scaleX", fallback: 1 }),
+ scaleY: readNumberParam({ params, key: "transform.scaleY", fallback: 1 }),
+ position: {
+ x: readNumberParam({ params, key: "transform.positionX", fallback: 0 }),
+ y: readNumberParam({ params, key: "transform.positionY", fallback: 0 }),
+ },
+ rotate: readNumberParam({ params, key: "transform.rotate", fallback: 0 }),
+ };
+}
+
+export function readOpacityFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): number {
+ return readNumberParam({ params, key: "opacity", fallback: 1 });
+}
+
+export function readBlendModeFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): BlendMode {
+ const value = params.blendMode;
+ return typeof value === "string" && isBlendMode(value) ? value : "normal";
+}
+
+function readNumberParam({
+ params,
+ key,
+ fallback,
+}: {
+ params: ParamValues;
+ key: string;
+ fallback: number;
+}): number {
+ const value = params[key];
+ return typeof value === "number" ? value : fallback;
+}
+
+function isBlendMode(value: string): value is BlendMode {
+ return (
+ value === "normal" ||
+ value === "darken" ||
+ value === "multiply" ||
+ value === "color-burn" ||
+ value === "lighten" ||
+ value === "screen" ||
+ value === "plus-lighter" ||
+ value === "color-dodge" ||
+ value === "overlay" ||
+ value === "soft-light" ||
+ value === "hard-light" ||
+ value === "difference" ||
+ value === "exclusion" ||
+ value === "hue" ||
+ value === "saturation" ||
+ value === "color" ||
+ value === "luminosity"
+ );
+}
diff --git a/apps/web/src/selection/hooks/use-box-select.ts b/apps/web/src/selection/hooks/use-box-select.ts
index 90a584b5..3bbff14e 100644
--- a/apps/web/src/selection/hooks/use-box-select.ts
+++ b/apps/web/src/selection/hooks/use-box-select.ts
@@ -4,15 +4,40 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type {
BoxSelectionSnapshot,
ResolveIntersections,
+ SelectionBoxBounds,
} from "@/selection/types";
interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
+ bounds: SelectionBoxBounds | null;
isActive: boolean;
isAdditive: boolean;
}
+function getSelectionBoxBounds({
+ container,
+ startPos,
+ currentPos,
+}: {
+ container: HTMLElement;
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+}): SelectionBoxBounds {
+ const containerRect = container.getBoundingClientRect();
+ const startX = startPos.x - containerRect.left;
+ const startY = startPos.y - containerRect.top;
+ const currentX = currentPos.x - containerRect.left;
+ const currentY = currentPos.y - containerRect.top;
+
+ return {
+ left: Math.min(startX, currentX),
+ top: Math.min(startY, currentY),
+ width: Math.abs(currentX - startX),
+ height: Math.abs(currentY - startY),
+ };
+}
+
export function useBoxSelect<TId>({
containerRef,
resolveIntersections,
@@ -40,33 +65,43 @@ export function useBoxSelect<TId>({
const [selectionBox, setSelectionBox] =
useState<SelectionBoxState<TId> | null>(null);
const justFinishedSelectingRef = useRef(false);
- const shouldStartSelectionCheck = shouldStartSelection ?? (() => true);
- const getIsAdditiveSelectionCheck =
- getIsAdditiveSelection ??
- ((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey);
const handleMouseDown = useCallback(
(event: React.MouseEvent<Element>) => {
- const canStartSelection = shouldStartSelectionCheck(event);
+ const canStartSelection = shouldStartSelection
+ ? shouldStartSelection(event)
+ : true;
if (!isEnabled || event.button !== 0 || !canStartSelection) {
return;
}
+ const startPos = { x: event.clientX, y: event.clientY };
+ const container = containerRef.current;
setSelectionBox({
- startPos: { x: event.clientX, y: event.clientY },
- currentPos: { x: event.clientX, y: event.clientY },
+ startPos,
+ currentPos: startPos,
+ bounds: container
+ ? getSelectionBoxBounds({
+ container,
+ startPos,
+ currentPos: startPos,
+ })
+ : null,
isActive: false,
- isAdditive: getIsAdditiveSelectionCheck(event),
+ isAdditive: getIsAdditiveSelection
+ ? getIsAdditiveSelection(event)
+ : event.ctrlKey || event.metaKey,
initialSelectedIds: selectedIds,
initialAnchorId: anchorId,
});
},
[
anchorId,
- getIsAdditiveSelectionCheck,
+ containerRef,
+ getIsAdditiveSelection,
isEnabled,
selectedIds,
- shouldStartSelectionCheck,
+ shouldStartSelection,
],
);
@@ -98,11 +133,20 @@ export function useBoxSelect<TId>({
}
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
+ const currentPos = { x: clientX, y: clientY };
const deltaX = Math.abs(clientX - selectionBox.startPos.x);
const deltaY = Math.abs(clientY - selectionBox.startPos.y);
+ const container = containerRef.current;
const nextSelectionBox = {
...selectionBox,
- currentPos: { x: clientX, y: clientY },
+ currentPos,
+ bounds: container
+ ? getSelectionBoxBounds({
+ container,
+ startPos: selectionBox.startPos,
+ currentPos,
+ })
+ : null,
isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive,
};
@@ -133,26 +177,26 @@ export function useBoxSelect<TId>({
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
- }, [selectionBox, updateSelection]);
+ }, [containerRef, selectionBox, updateSelection]);
useEffect(() => {
if (!selectionBox) {
return;
}
+ const container = containerRef.current;
const previousBodyUserSelect = document.body.style.userSelect;
- const previousContainerUserSelect =
- containerRef.current?.style.userSelect ?? "";
+ const previousContainerUserSelect = container?.style.userSelect ?? "";
document.body.style.userSelect = "none";
- if (containerRef.current) {
- containerRef.current.style.userSelect = "none";
+ if (container) {
+ container.style.userSelect = "none";
}
return () => {
document.body.style.userSelect = previousBodyUserSelect;
- if (containerRef.current) {
- containerRef.current.style.userSelect = previousContainerUserSelect;
+ if (container) {
+ container.style.userSelect = previousContainerUserSelect;
}
};
}, [containerRef, selectionBox]);
@@ -162,7 +206,10 @@ export function useBoxSelect<TId>({
}, []);
return {
- selectionBox,
+ selectionBox:
+ selectionBox?.isActive && selectionBox.bounds
+ ? { bounds: selectionBox.bounds }
+ : null,
handleMouseDown,
isSelecting: selectionBox?.isActive ?? false,
shouldIgnoreClick,
diff --git a/apps/web/src/selection/hooks/use-selection-scope.ts b/apps/web/src/selection/hooks/use-selection-scope.ts
index ad4d2ce8..7fddab08 100644
--- a/apps/web/src/selection/hooks/use-selection-scope.ts
+++ b/apps/web/src/selection/hooks/use-selection-scope.ts
@@ -1,28 +1,25 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useState } from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useSelectionContext } from "@/selection/context";
import { activateScope, type ScopeEntry } from "@/selection/scope";
export function useSelectionScope() {
const { selectedIds, clearSelection } = useSelectionContext();
- const hasSelectionRef = useRef(selectedIds.length > 0);
- const clearSelectionRef = useRef(clearSelection);
const hasSelection = selectedIds.length > 0;
-
- hasSelectionRef.current = hasSelection;
- clearSelectionRef.current = clearSelection;
-
- const entryRef = useRef<ScopeEntry>({
+ const hasSelectionRef = useCommittedRef(hasSelection);
+ const clearSelectionRef = useCommittedRef(clearSelection);
+ const [entry] = useState<ScopeEntry>(() => ({
hasSelection: () => hasSelectionRef.current,
clear: () => {
clearSelectionRef.current();
},
- });
+ }));
useEffect(() => {
if (!hasSelection) {
return;
}
- return activateScope({ entry: entryRef.current });
- }, [hasSelection]);
+ return activateScope({ entry });
+ }, [entry, hasSelection]);
}
diff --git a/apps/web/src/selection/selectable-item.tsx b/apps/web/src/selection/selectable-item.tsx
index cc9e1dbf..9735c4a8 100644
--- a/apps/web/src/selection/selectable-item.tsx
+++ b/apps/web/src/selection/selectable-item.tsx
@@ -6,7 +6,13 @@ import { SELECTABLE_ITEM_ATTRIBUTE } from "@/selection/attributes";
import type { SelectableItemProps } from "@/selection/types";
import { cn } from "@/utils/ui";
-function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
+function setForwardedRef<T>({
+ ref,
+ value,
+}: {
+ ref: React.ForwardedRef<T>;
+ value: T | null;
+}) {
if (typeof ref === "function") {
ref(value);
return;
@@ -55,7 +61,7 @@ export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>(
const handleRef = useCallback(
(element: HTMLDivElement | null) => {
registerItem(id, element);
- setForwardedRef(forwardedRef, element);
+ setForwardedRef({ ref: forwardedRef, value: element });
},
[forwardedRef, id, registerItem],
);
diff --git a/apps/web/src/selection/selectable-surface.tsx b/apps/web/src/selection/selectable-surface.tsx
index 24166daf..12682ba8 100644
--- a/apps/web/src/selection/selectable-surface.tsx
+++ b/apps/web/src/selection/selectable-surface.tsx
@@ -167,14 +167,15 @@ export function SelectableSurface({
[],
);
- const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({
- containerRef,
- resolveIntersections,
- selectedIds: selectionState.selectedIds,
- anchorId: selectionState.anchorId,
- onSelectionChange: handleBoxSelectionChange,
- shouldStartSelection,
- });
+ const { selectionBox, handleMouseDown, isSelecting, shouldIgnoreClick } =
+ useBoxSelect({
+ containerRef,
+ resolveIntersections,
+ selectedIds: selectionState.selectedIds,
+ anchorId: selectionState.anchorId,
+ onSelectionChange: handleBoxSelectionChange,
+ shouldStartSelection,
+ });
const handleBackgroundClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
@@ -236,7 +237,7 @@ export function SelectableSurface({
return () => clearTimeout(timer);
}, [getItemElement, onRevealComplete, revealId]);
- const isBoxSelecting = selectionBox?.isActive ?? false;
+ const isBoxSelecting = isSelecting;
const contextValue = useMemo(() => {
return {
@@ -276,12 +277,7 @@ export function SelectableSurface({
onKeyDown={handleBackgroundKeyDown}
>
{children}
- <SelectionBox
- startPos={selectionBox?.startPos ?? null}
- currentPos={selectionBox?.currentPos ?? null}
- containerRef={containerRef}
- isActive={selectionBox?.isActive ?? false}
- />
+ <SelectionBox bounds={selectionBox?.bounds ?? null} />
</div>
</SelectionContext.Provider>
);
diff --git a/apps/web/src/selection/selection-box.tsx b/apps/web/src/selection/selection-box.tsx
index e30f574d..2f2bf933 100644
--- a/apps/web/src/selection/selection-box.tsx
+++ b/apps/web/src/selection/selection-box.tsx
@@ -1,49 +1,22 @@
"use client";
-import { useMemo } from "react";
+import type { SelectionBoxBounds } from "@/selection/types";
interface SelectionBoxProps {
- startPos: { x: number; y: number } | null;
- currentPos: { x: number; y: number } | null;
- containerRef: React.RefObject<HTMLElement | null>;
- isActive: boolean;
+ bounds: SelectionBoxBounds | null;
}
-export function SelectionBox({
- startPos,
- currentPos,
- containerRef,
- isActive,
-}: SelectionBoxProps) {
- const selectionBoxStyle = useMemo(() => {
- if (!isActive || !startPos || !currentPos || !containerRef.current) {
- return null;
- }
-
- const containerRect = containerRef.current.getBoundingClientRect();
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const currentX = currentPos.x - containerRect.left;
- const currentY = currentPos.y - containerRect.top;
-
- const left = Math.min(startX, currentX);
- const top = Math.min(startY, currentY);
- const width = Math.abs(currentX - startX);
- const height = Math.abs(currentY - startY);
-
- return {
- left: `${left}px`,
- top: `${top}px`,
- width: `${width}px`,
- height: `${height}px`,
- };
- }, [containerRef, currentPos, isActive, startPos]);
-
- if (!selectionBoxStyle) return null;
+export function SelectionBox({ bounds }: SelectionBoxProps) {
+ if (!bounds) return null;
return (
<div
- style={selectionBoxStyle}
+ style={{
+ left: `${bounds.left}px`,
+ top: `${bounds.top}px`,
+ width: `${bounds.width}px`,
+ height: `${bounds.height}px`,
+ }}
className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
/>
);
diff --git a/apps/web/src/selection/types.ts b/apps/web/src/selection/types.ts
index 31520788..a797d6c0 100644
--- a/apps/web/src/selection/types.ts
+++ b/apps/web/src/selection/types.ts
@@ -8,6 +8,13 @@ export interface BoxSelectionSnapshot<TId = string> {
initialAnchorId: TId | null;
}
+export interface SelectionBoxBounds {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
export interface BoxSelectionChange<TId = string>
extends BoxSelectionSnapshot<TId> {
intersectedIds: TId[];
diff --git a/apps/web/src/services/renderer/canvas-renderer.ts b/apps/web/src/services/renderer/canvas-renderer.ts
index 4a002675..58294e9c 100644
--- a/apps/web/src/services/renderer/canvas-renderer.ts
+++ b/apps/web/src/services/renderer/canvas-renderer.ts
@@ -1,5 +1,6 @@
import type { FrameRate } from "opencut-wasm";
import type { AnyBaseNode } from "./nodes/base-node";
+import { createCanvasSurface } from "./canvas-utils";
import { buildFrameDescriptor } from "./compositor/frame-descriptor";
import { wasmCompositor } from "./compositor/wasm-compositor";
import { resolveRenderTree } from "./resolve";
@@ -16,8 +17,8 @@ export type CanvasRendererParams = {
};
export class CanvasRenderer {
- canvas: OffscreenCanvas | HTMLCanvasElement;
- context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
+ canvas: OffscreenCanvas;
+ context: OffscreenCanvasRenderingContext2D;
width: number;
height: number;
fps: FrameRate;
@@ -27,22 +28,9 @@ export class CanvasRenderer {
this.height = height;
this.fps = fps;
- try {
- this.canvas = new OffscreenCanvas(width, height);
- } catch {
- this.canvas = document.createElement("canvas");
- this.canvas.width = width;
- this.canvas.height = height;
- }
-
- const context = this.canvas.getContext("2d");
- if (!context) {
- throw new Error("Failed to get canvas context");
- }
-
- this.context = context as
- | OffscreenCanvasRenderingContext2D
- | CanvasRenderingContext2D;
+ const surface = createCanvasSurface({ width, height });
+ this.canvas = surface.canvas;
+ this.context = surface.context;
}
getOutputCanvas(): HTMLCanvasElement {
@@ -57,20 +45,9 @@ export class CanvasRenderer {
this.width = width;
this.height = height;
- if (this.canvas instanceof OffscreenCanvas) {
- this.canvas = new OffscreenCanvas(width, height);
- } else {
- this.canvas.width = width;
- this.canvas.height = height;
- }
-
- const context = this.canvas.getContext("2d");
- if (!context) {
- throw new Error("Failed to get canvas context");
- }
- this.context = context as
- | OffscreenCanvasRenderingContext2D
- | CanvasRenderingContext2D;
+ const surface = createCanvasSurface({ width, height });
+ this.canvas = surface.canvas;
+ this.context = surface.context;
}
async render({ node, time }: { node: AnyBaseNode; time: number }) {
diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts
index 093168e6..6981e6d5 100644
--- a/apps/web/src/services/renderer/canvas-utils.ts
+++ b/apps/web/src/services/renderer/canvas-utils.ts
@@ -1,16 +1,17 @@
-export function createOffscreenCanvas({
+export function createCanvasSurface({
width,
height,
}: {
width: number;
height: number;
-}): OffscreenCanvas | HTMLCanvasElement {
- try {
- return new OffscreenCanvas(width, height);
- } catch {
- const canvas = document.createElement("canvas");
- canvas.width = width;
- canvas.height = height;
- return canvas;
+}): {
+ canvas: OffscreenCanvas;
+ context: OffscreenCanvasRenderingContext2D;
+} {
+ const canvas = new OffscreenCanvas(width, height);
+ const context = canvas.getContext("2d");
+ if (!context) {
+ throw new Error("Failed to create 2D rendering context");
}
+ return { canvas, context };
}
diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
index 190a0237..d5ab88c3 100644
--- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts
+++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
@@ -1,9 +1,9 @@
import { drawCssBackground } from "@/gradients";
-import { masksRegistry } from "@/masks";
+import { getMaskDefinition } from "@/masks";
import { incrementCounter } from "@/diagnostics/render-perf";
import type { AnyBaseNode } from "../nodes/base-node";
import type { CanvasRenderer } from "../canvas-renderer";
-import { createOffscreenCanvas } from "../canvas-utils";
+import { createCanvasSurface } from "../canvas-utils";
import { BlurBackgroundNode } from "../nodes/blur-background-node";
import { ColorNode } from "../nodes/color-node";
import { EffectLayerNode } from "../nodes/effect-layer-node";
@@ -390,58 +390,71 @@ function buildMaskArtifacts({
return { mask: null, strokeLayer: null };
}
- const definition = masksRegistry.get(mask.type);
+ const definition = getMaskDefinition(mask.type);
if (definition.isActive?.(mask.params) === false) {
return { mask: null, strokeLayer: null };
}
- let feather = mask.params.feather;
- const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
- const shouldRenderMaskDirectly =
- canRenderMaskDirectly &&
- (!definition.renderer.buildPath ||
- (mask.params.feather > 0 &&
- definition.renderer.renderMaskHandlesFeather));
- if (
- shouldRenderMaskDirectly &&
- definition.renderer.renderMaskHandlesFeather
- ) {
- feather = 0;
- }
+ const { body } = definition.renderer;
+ const usesOpaqueFastPath =
+ body.kind === "drawWithFeather" &&
+ mask.params.feather === 0 &&
+ Boolean(body.opaqueFastPath);
+ // drawWithFeather renderers encode feathering analytically in their canvas output
+ // (e.g. split mask uses a linear gradient instead of JFA). The descriptor feather is
+ // zeroed so the GPU compositor copies the mask texture as-is and does not run a second
+ // JFA feather pass on top of an already-soft texture.
+ const feather = body.kind === "drawWithFeather" ? 0 : mask.params.feather;
const maskTextureId = `${path}:mask`;
const { width: canvasWidth, height: canvasHeight } = renderer;
- const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`;
+ const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:body=${body.kind}:fastPath=${usesOpaqueFastPath}`;
const drawMask: TextureCanvasDrawFn = (ctx) => {
- const elementMaskCanvas = createOffscreenCanvas({
- width: Math.round(transform.width),
- height: Math.round(transform.height),
- });
- const elementMaskCtx = elementMaskCanvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (!elementMaskCtx) return;
-
- if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
- definition.renderer.renderMask({
- resolvedParams: mask.params,
- ctx: elementMaskCtx,
+ const { canvas: elementMaskCanvas, context: elementMaskCtx } =
+ createCanvasSurface({
width: Math.round(transform.width),
height: Math.round(transform.height),
- feather: mask.params.feather,
- });
- } else if (definition.renderer.buildPath) {
- const path2d = definition.renderer.buildPath({
- resolvedParams: mask.params,
- width: transform.width,
- height: transform.height,
});
- elementMaskCtx.fillStyle = "white";
- elementMaskCtx.fill(path2d);
- } else {
- return;
+
+ switch (body.kind) {
+ case "fillPath": {
+ const path2d = body.buildPath({
+ resolvedParams: mask.params,
+ width: transform.width,
+ height: transform.height,
+ });
+ elementMaskCtx.fillStyle = "white";
+ elementMaskCtx.fill(path2d);
+ break;
+ }
+ case "drawOpaque":
+ body.drawOpaque({
+ resolvedParams: mask.params,
+ ctx: elementMaskCtx,
+ width: Math.round(transform.width),
+ height: Math.round(transform.height),
+ });
+ break;
+ case "drawWithFeather":
+ if (usesOpaqueFastPath && body.opaqueFastPath) {
+ const path2d = body.opaqueFastPath.buildPath({
+ resolvedParams: mask.params,
+ width: transform.width,
+ height: transform.height,
+ });
+ elementMaskCtx.fillStyle = "white";
+ elementMaskCtx.fill(path2d);
+ } else {
+ body.drawWithFeather({
+ resolvedParams: mask.params,
+ ctx: elementMaskCtx,
+ width: Math.round(transform.width),
+ height: Math.round(transform.height),
+ feather: mask.params.feather,
+ });
+ }
+ break;
}
drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform });
@@ -455,50 +468,38 @@ function buildMaskArtifacts({
draw: drawMask,
});
- const hasStroke =
- mask.params.strokeWidth > 0 &&
- (definition.renderer.renderStroke ||
- definition.renderer.buildStrokePath ||
- definition.renderer.buildPath);
+ const stroke = definition.renderer.stroke;
+ const hasStroke = mask.params.strokeWidth > 0 && Boolean(stroke);
let strokeLayer: FrameItemDescriptor | null = null;
- if (hasStroke) {
+ if (hasStroke && stroke) {
const strokeTextureId = `${path}:mask-stroke`;
- const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`;
+ const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:stroke=${stroke.kind}`;
const drawStroke: TextureCanvasDrawFn = (ctx) => {
- const strokeCanvas = createOffscreenCanvas({
+ const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({
width: Math.round(transform.width),
height: Math.round(transform.height),
});
- const strokeCtx = strokeCanvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (!strokeCtx) return;
- if (definition.renderer.renderStroke) {
- definition.renderer.renderStroke({
- resolvedParams: mask.params,
- ctx: strokeCtx,
- width: transform.width,
- height: transform.height,
- });
- } else {
- const strokePath =
- definition.renderer.buildStrokePath?.({
+ switch (stroke.kind) {
+ case "renderStroke":
+ stroke.renderStroke({
resolvedParams: mask.params,
+ ctx: strokeCtx,
width: transform.width,
height: transform.height,
- }) ??
- definition.renderer.buildPath?.({
+ });
+ break;
+ case "strokeFromPath": {
+ const strokePath = stroke.buildStrokePath({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
- }) ??
- null;
- if (!strokePath) return;
- strokeCtx.strokeStyle = mask.params.strokeColor;
- strokeCtx.lineWidth = mask.params.strokeWidth;
- strokeCtx.stroke(strokePath);
+ });
+ strokeCtx.strokeStyle = mask.params.strokeColor;
+ strokeCtx.lineWidth = mask.params.strokeWidth;
+ strokeCtx.stroke(strokePath);
+ break;
+ }
}
drawTransformedCanvas({ ctx, source: strokeCanvas, transform });
diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts
index c4cacde0..cbfb1a31 100644
--- a/apps/web/src/services/renderer/effect-preview.ts
+++ b/apps/web/src/services/renderer/effect-preview.ts
@@ -1,4 +1,4 @@
-import { createOffscreenCanvas } from "./canvas-utils";
+import { createCanvasSurface } from "./canvas-utils";
import { effectsRegistry, resolveEffectPasses } from "@/effects";
import { buildDefaultParamValues } from "@/params/registry";
import type { ParamValues } from "@/params";
@@ -8,7 +8,7 @@ const PREVIEW_SIZE = 160;
const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
class EffectPreviewService {
- private testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
+ private testSourceCanvas: OffscreenCanvas | null = null;
private previewImageElement: HTMLImageElement | null = null;
private onReadyCallbacks = new Set<() => void>();
@@ -98,7 +98,7 @@ class EffectPreviewService {
}: {
width: number;
height: number;
- }): OffscreenCanvas | HTMLCanvasElement | null {
+ }): OffscreenCanvas | null {
const isImageReady =
this.previewImageElement?.complete &&
(this.previewImageElement.naturalWidth ?? 0) > 0;
@@ -106,15 +106,8 @@ class EffectPreviewService {
return null;
}
- const canvas = createOffscreenCanvas({ width, height });
- const ctx = canvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (!ctx) {
- throw new Error("failed to get 2d context for test source");
- }
- ctx.drawImage(this.previewImageElement, 0, 0, width, height);
+ const { canvas, context } = createCanvasSurface({ width, height });
+ context.drawImage(this.previewImageElement, 0, 0, width, height);
return canvas;
}
@@ -124,7 +117,7 @@ class EffectPreviewService {
}: {
width: number;
height: number;
- }): CanvasImageSource | null {
+ }): OffscreenCanvas | null {
if (
!this.testSourceCanvas ||
this.testSourceCanvas.width !== width ||
@@ -141,17 +134,17 @@ class EffectPreviewService {
height,
passes,
}: {
- source: CanvasImageSource;
+ source: OffscreenCanvas;
width: number;
height: number;
passes: ReturnType<typeof resolveEffectPasses>;
- }): OffscreenCanvas | HTMLCanvasElement {
+ }): OffscreenCanvas {
return gpuRenderer.applyEffect({
source,
width,
height,
passes,
- }) as OffscreenCanvas | HTMLCanvasElement;
+ });
}
}
diff --git a/apps/web/src/services/renderer/gpu-renderer.ts b/apps/web/src/services/renderer/gpu-renderer.ts
index c61b6b98..c1be3f1a 100644
--- a/apps/web/src/services/renderer/gpu-renderer.ts
+++ b/apps/web/src/services/renderer/gpu-renderer.ts
@@ -34,23 +34,17 @@ export const gpuRenderer = {
height,
passes,
}: {
- source: CanvasImageSource;
+ source: OffscreenCanvas;
width: number;
height: number;
passes: EffectPass[];
- }): CanvasImageSource {
+ }): OffscreenCanvas {
if (passes.length === 0 || !gpuAvailable) {
return source;
}
- const sourceCanvas = ensureOffscreenCanvas({
- source,
- width,
- height,
- label: "effect source",
- });
return applyEffectPasses({
- source: sourceCanvas,
+ source,
width,
height,
passes: serializeEffectPasses(passes),
@@ -63,23 +57,17 @@ export const gpuRenderer = {
height,
feather,
}: {
- maskCanvas: CanvasImageSource;
+ maskCanvas: OffscreenCanvas;
width: number;
height: number;
feather: number;
- }): CanvasImageSource {
+ }): OffscreenCanvas {
if (!gpuAvailable) {
return maskCanvas;
}
- const sourceCanvas = ensureOffscreenCanvas({
- source: maskCanvas,
- width,
- height,
- label: "mask source",
- });
return applyMaskFeatherWasm({
- mask: sourceCanvas,
+ mask: maskCanvas,
width,
height,
feather,
@@ -87,35 +75,6 @@ export const gpuRenderer = {
},
};
-function ensureOffscreenCanvas({
- source,
- width,
- height,
- label,
-}: {
- source: CanvasImageSource;
- width: number;
- height: number;
- label: string;
-}): OffscreenCanvas {
- if (source instanceof OffscreenCanvas) {
- return source;
- }
-
- if (typeof OffscreenCanvas === "undefined") {
- throw new Error(`OffscreenCanvas is required for the GPU ${label}`);
- }
-
- const canvas = new OffscreenCanvas(width, height);
- const context = canvas.getContext("2d");
- if (!context) {
- throw new Error(`Failed to get 2d context for the GPU ${label}`);
- }
- context.clearRect(0, 0, width, height);
- context.drawImage(source, 0, 0, width, height);
- return canvas;
-}
-
function serializeEffectPasses(passes: EffectPass[]) {
return passes.map((pass) => ({
shader: pass.shader,
diff --git a/apps/web/src/services/renderer/mask-feather.ts b/apps/web/src/services/renderer/mask-feather.ts
index 4ea7eb2b..dde4decc 100644
--- a/apps/web/src/services/renderer/mask-feather.ts
+++ b/apps/web/src/services/renderer/mask-feather.ts
@@ -6,15 +6,15 @@ export function applyMaskFeather({
height,
feather,
}: {
- maskCanvas: CanvasImageSource;
+ maskCanvas: OffscreenCanvas;
width: number;
height: number;
feather: number;
-}): OffscreenCanvas | HTMLCanvasElement {
+}): OffscreenCanvas {
return gpuRenderer.applyMaskFeather({
maskCanvas,
width,
height,
feather,
- }) as OffscreenCanvas | HTMLCanvasElement;
+ });
}
diff --git a/apps/web/src/services/renderer/nodes/graphic-node.ts b/apps/web/src/services/renderer/nodes/graphic-node.ts
index 2d022510..798b56a0 100644
--- a/apps/web/src/services/renderer/nodes/graphic-node.ts
+++ b/apps/web/src/services/renderer/nodes/graphic-node.ts
@@ -1,4 +1,4 @@
-import { createOffscreenCanvas } from "../canvas-utils";
+import { createCanvasSurface } from "../canvas-utils";
import {
DEFAULT_GRAPHIC_SOURCE_SIZE,
getGraphicDefinition,
@@ -25,7 +25,7 @@ export class GraphicNode extends VisualNode<
ResolvedGraphicNodeState
> {
private cachedKey: string | null = null;
- private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null;
+ private cachedSource: OffscreenCanvas | null = null;
constructor(params: GraphicNodeParams) {
super(params);
@@ -36,7 +36,7 @@ export class GraphicNode extends VisualNode<
resolvedParams,
}: {
resolvedParams: ParamValues;
- }): OffscreenCanvas | HTMLCanvasElement | null {
+ }): OffscreenCanvas {
const definition = getGraphicDefinition({
definitionId: this.params.definitionId,
});
@@ -48,20 +48,13 @@ export class GraphicNode extends VisualNode<
return this.cachedSource;
}
- const canvas = createOffscreenCanvas({
+ const { canvas, context } = createCanvasSurface({
width: DEFAULT_GRAPHIC_SOURCE_SIZE,
height: DEFAULT_GRAPHIC_SOURCE_SIZE,
});
- const ctx = canvas.getContext("2d") as
- | CanvasRenderingContext2D
- | OffscreenCanvasRenderingContext2D
- | null;
- if (!ctx) {
- return null;
- }
definition.render({
- ctx,
+ ctx: context,
params: resolvedParams,
width: DEFAULT_GRAPHIC_SOURCE_SIZE,
height: DEFAULT_GRAPHIC_SOURCE_SIZE,
diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts
index d8c56803..4f475a2d 100644
--- a/apps/web/src/services/renderer/nodes/image-node.ts
+++ b/apps/web/src/services/renderer/nodes/image-node.ts
@@ -17,10 +17,13 @@ export interface CachedImageSource {
const imageSourceCache = new Map<string, Promise<CachedImageSource>>();
-export function loadImageSource(
- url: string,
- maxSourceSize?: number,
-): Promise<CachedImageSource> {
+export function loadImageSource({
+ url,
+ maxSourceSize,
+}: {
+ url: string;
+ maxSourceSize?: number;
+}): Promise<CachedImageSource> {
const cacheKey = `${url}::${maxSourceSize ?? "full"}`;
const cached = imageSourceCache.get(cacheKey);
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index 7bdf135c..6635a031 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -1,11 +1,14 @@
import { BaseNode } from "./base-node";
import type { TextElement } from "@/timeline";
import type { EffectPass } from "@/effects/types";
-import type { Transform } from "@/rendering";
+import type { BlendMode, Transform } from "@/rendering";
import { drawMeasuredTextLayout } from "@/text/primitives";
import type { MeasuredTextElement } from "@/text/measure-element";
export type TextNodeParams = TextElement & {
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
canvasCenter: { x: number; y: number };
canvasHeight: number;
textBaseline?: CanvasTextBaseline;
diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts
index 809f7103..5335de2d 100644
--- a/apps/web/src/services/renderer/resolve.ts
+++ b/apps/web/src/services/renderer/resolve.ts
@@ -1,4 +1,4 @@
-import { mediaTimeToSeconds } from "opencut-wasm";
+import { mediaTimeToSeconds, roundMediaTime } from "@/wasm";
import { getElementLocalTime } from "@/animation";
import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel";
import {
@@ -13,6 +13,7 @@ import {
resolveGraphicElementParamsAtTime,
} from "@/graphics";
import {
+ buildTextBackgroundFromElement,
getTextMeasurementContext,
measureTextElement,
} from "@/text/measure-element";
@@ -204,7 +205,7 @@ async function resolveVideoNode({
const frame = await videoCache.getFrameAt({
mediaId: node.params.mediaId,
file: node.params.file,
- time: mediaTimeToSeconds({ time: sourceTimeTicks }),
+ time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
});
if (!frame) {
return null;
@@ -235,10 +236,10 @@ async function resolveImageNode({
node: ImageNode;
context: ResolveContext;
}): Promise<ResolvedVisualSourceNodeState | null> {
- const source = await loadImageSource(
- node.params.url,
- node.params.maxSourceSize,
- );
+ const source = await loadImageSource({
+ url: node.params.url,
+ maxSourceSize: node.params.maxSourceSize,
+ });
const visualState = resolveVisualState({
params: node.params,
context,
@@ -330,6 +331,7 @@ function resolveTextNode({
elementStartTime: node.params.startTime,
elementDuration: node.params.duration,
});
+ const background = buildTextBackgroundFromElement({ element: node.params });
return {
transform: resolveTransformAtTime({
@@ -343,13 +345,16 @@ function resolveTextNode({
localTime,
}),
textColor: resolveColorAtTime({
- baseColor: node.params.color,
+ baseColor:
+ typeof node.params.params.color === "string"
+ ? node.params.params.color
+ : "#ffffff",
animations: node.params.animations,
propertyPath: "color",
localTime,
}),
backgroundColor: resolveColorAtTime({
- baseColor: node.params.background.color,
+ baseColor: background.color,
animations: node.params.animations,
propertyPath: "background.color",
localTime,
@@ -421,7 +426,7 @@ async function resolveBackdropSource({
const frame = await videoCache.getFrameAt({
mediaId: node.params.mediaId,
file: node.params.file,
- time: mediaTimeToSeconds({ time: sourceTimeTicks }),
+ time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
});
if (!frame) {
return null;
@@ -434,7 +439,7 @@ async function resolveBackdropSource({
};
}
- const source = await loadImageSource(node.params.url);
+ const source = await loadImageSource({ url: node.params.url });
return {
source: source.source,
width: source.width,
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index e79c62d4..431b8269 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -12,6 +12,11 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { AnyBaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/project/types";
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/background/blur";
+import {
+ buildTransformFromParams,
+ readBlendModeFromParams,
+ readOpacityFromParams,
+} from "@/rendering";
const PREVIEW_MAX_IMAGE_SIZE = 2048;
@@ -71,10 +76,10 @@ function buildTrackNodes({
trimStart: element.trimStart,
trimEnd: element.trimEnd,
retime: element.retime,
- transform: element.transform,
+ transform: buildTransformFromParams({ params: element.params }),
animations: element.animations,
- opacity: element.opacity,
- blendMode: element.blendMode,
+ opacity: readOpacityFromParams({ params: element.params }),
+ blendMode: readBlendModeFromParams({ params: element.params }),
effects: element.effects ?? [],
masks: element.masks ?? [],
}),
@@ -88,10 +93,10 @@ function buildTrackNodes({
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
- transform: element.transform,
+ transform: buildTransformFromParams({ params: element.params }),
animations: element.animations,
- opacity: element.opacity,
- blendMode: element.blendMode,
+ opacity: readOpacityFromParams({ params: element.params }),
+ blendMode: readBlendModeFromParams({ params: element.params }),
effects: element.effects ?? [],
masks: element.masks ?? [],
...(isPreview && {
@@ -106,6 +111,9 @@ function buildTrackNodes({
nodes.push(
new TextNode({
...element,
+ transform: buildTransformFromParams({ params: element.params }),
+ opacity: readOpacityFromParams({ params: element.params }),
+ blendMode: readBlendModeFromParams({ params: element.params }),
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
canvasHeight: canvasSize.height,
textBaseline: "middle",
@@ -124,10 +132,10 @@ function buildTrackNodes({
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
- transform: element.transform,
+ transform: buildTransformFromParams({ params: element.params }),
animations: element.animations,
- opacity: element.opacity,
- blendMode: element.blendMode,
+ opacity: readOpacityFromParams({ params: element.params }),
+ blendMode: readBlendModeFromParams({ params: element.params }),
effects: element.effects ?? [],
}),
);
@@ -142,10 +150,10 @@ function buildTrackNodes({
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
- transform: element.transform,
+ transform: buildTransformFromParams({ params: element.params }),
animations: element.animations,
- opacity: element.opacity,
- blendMode: element.blendMode,
+ opacity: readOpacityFromParams({ params: element.params }),
+ blendMode: readBlendModeFromParams({ params: element.params }),
effects: element.effects ?? [],
masks: element.masks ?? [],
}),
diff --git a/apps/web/src/services/storage/indexeddb-adapter.ts b/apps/web/src/services/storage/indexeddb-adapter.ts
index 2df4feaa..4a7498b6 100644
--- a/apps/web/src/services/storage/indexeddb-adapter.ts
+++ b/apps/web/src/services/storage/indexeddb-adapter.ts
@@ -5,7 +5,15 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> {
private storeName: string;
private version: number;
- constructor(dbName: string, storeName: string, version = 1) {
+ constructor({
+ dbName,
+ storeName,
+ version = 1,
+ }: {
+ dbName: string;
+ storeName: string;
+ version?: number;
+ }) {
this.dbName = dbName;
this.storeName = storeName;
this.version = version;
@@ -39,7 +47,13 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> {
});
}
- async set(key: string, value: T): Promise<void> {
+ async set({
+ key,
+ value,
+ }: {
+ key: string;
+ value: T;
+ }): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
diff --git a/apps/web/src/services/storage/migrations/AGENTS.md b/apps/web/src/services/storage/migrations/AGENTS.md
new file mode 100644
index 00000000..5aed00a1
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/AGENTS.md
@@ -0,0 +1,10 @@
+# Agents.md
+
+## Migration Data Policy
+
+Migrations are additive only.
+
+- Do not delete, rename, or replace persisted data in a migration.
+- When a new storage shape is needed, add the new fields alongside the old fields.
+- Runtime code may prefer or ignore old fields, but the migration must preserve them for future down migrations.
+- If old data should ever be removed, that is a separate explicit cleanup task, not part of a version migration. \ No newline at end of file
diff --git a/apps/web/src/services/storage/migrations/__tests__/helpers.ts b/apps/web/src/services/storage/migrations/__tests__/helpers.ts
new file mode 100644
index 00000000..381cb935
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/helpers.ts
@@ -0,0 +1,39 @@
+/**
+ * Type-safe accessors for navigating migration output.
+ *
+ * Migrations input/output `Record<string, unknown>` because they handle
+ * potentially malformed data from older versions. Tests need to inspect
+ * specific properties of the migrated result without using type assertions.
+ *
+ * These helpers narrow through type guards (Array.isArray + a record
+ * predicate), so the file contains no unsafe assertions despite turning
+ * unknown into concrete shapes.
+ */
+
+function describe(value: unknown): string {
+ if (value === null) return "null";
+ if (Array.isArray(value)) return "array";
+ return typeof value;
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export function asRecord(value: unknown): Record<string, unknown> {
+ if (!isRecord(value)) {
+ throw new Error(`Expected record, got ${describe(value)}`);
+ }
+ return value;
+}
+
+export function asArray(value: unknown): unknown[] {
+ if (!Array.isArray(value)) {
+ throw new Error(`Expected array, got ${describe(value)}`);
+ }
+ return value;
+}
+
+export function asRecordArray(value: unknown): Record<string, unknown>[] {
+ return asArray(value).map(asRecord);
+}
diff --git a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts
index 2cd67da8..73b64fed 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts
@@ -8,6 +8,7 @@ import {
v0ProjectWithMetadata,
v1Project,
} from "./fixtures";
+import { asArray, asRecord, asRecordArray } from "./helpers";
describe("V0 to V1 Migration", () => {
const fixedDate = new Date("2024-06-01T12:00:00.000Z");
@@ -22,7 +23,7 @@ describe("V0 to V1 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(1);
expect(Array.isArray(result.project.scenes)).toBe(true);
- expect((result.project.scenes as unknown[]).length).toBe(1);
+ expect(asArray(result.project.scenes).length).toBe(1);
expect(result.project.currentSceneId).toBeDefined();
});
@@ -32,7 +33,7 @@ describe("V0 to V1 Migration", () => {
options: { now: fixedDate },
});
- const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const scenes = asRecordArray(result.project.scenes);
const mainScene = scenes[0];
expect(mainScene.isMain).toBe(true);
@@ -48,7 +49,7 @@ describe("V0 to V1 Migration", () => {
options: { now: fixedDate },
});
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
expect(metadata.updatedAt).toBe(fixedDate.toISOString());
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
index 0840ac76..6670c479 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts
@@ -11,6 +11,7 @@ import {
v1ProjectWithMultipleScenes,
v2Project,
} from "./fixtures";
+import { asRecord, asRecordArray } from "./helpers";
const DEFAULT_FPS = 30;
const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
@@ -25,7 +26,7 @@ describe("V1 to V2 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(2);
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
expect(metadata.id).toBe(v1Project.id);
expect(metadata.name).toBe(v1Project.name);
expect(typeof metadata.createdAt).toBe("string");
@@ -35,7 +36,7 @@ describe("V1 to V2 Migration", () => {
test("creates settings object from flat properties", () => {
const result = transformProjectV1ToV2({ project: v1Project });
- const settings = result.project.settings as Record<string, unknown>;
+ const settings = asRecord(result.project.settings);
expect(settings.fps).toBe(v1Project.fps);
expect(settings.canvasSize).toEqual(v1Project.canvasSize);
expect(settings.originalCanvasSize).toBe(null);
@@ -44,8 +45,8 @@ describe("V1 to V2 Migration", () => {
test("converts color background correctly", () => {
const result = transformProjectV1ToV2({ project: v1Project });
- const settings = result.project.settings as Record<string, unknown>;
- const background = settings.background as Record<string, unknown>;
+ const settings = asRecord(result.project.settings);
+ const background = asRecord(settings.background);
expect(background.type).toBe("color");
expect(background.color).toBe(v1Project.backgroundColor);
});
@@ -58,8 +59,8 @@ describe("V1 to V2 Migration", () => {
};
const result = transformProjectV1ToV2({ project: projectWithBlur });
- const settings = result.project.settings as Record<string, unknown>;
- const background = settings.background as Record<string, unknown>;
+ const settings = asRecord(result.project.settings);
+ const background = asRecord(settings.background);
expect(background.type).toBe("blur");
expect(background.blurIntensity).toBe(30);
});
@@ -67,7 +68,7 @@ describe("V1 to V2 Migration", () => {
test("applies legacy bookmarks to main scene", () => {
const result = transformProjectV1ToV2({ project: v1Project });
- const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const scenes = asRecordArray(result.project.scenes);
const mainScene = scenes.find((s) => s.isMain === true);
expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks);
});
@@ -77,7 +78,7 @@ describe("V1 to V2 Migration", () => {
project: v1ProjectWithMultipleScenes,
});
- const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const scenes = asRecordArray(result.project.scenes);
const introScene = scenes.find((s) => s.name === "Intro");
expect(introScene?.bookmarks).toEqual([1.0]);
});
@@ -102,7 +103,7 @@ describe("V1 to V2 Migration", () => {
});
expect(result.skipped).toBe(false);
- const settings = result.project.settings as Record<string, unknown>;
+ const settings = asRecord(result.project.settings);
expect(settings.fps).toBe(DEFAULT_FPS);
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
});
@@ -115,11 +116,11 @@ describe("V1 to V2 Migration", () => {
};
const result = transformProjectV1ToV2({ project: minimalProject });
- const settings = result.project.settings as Record<string, unknown>;
+ const settings = asRecord(result.project.settings);
expect(settings.fps).toBe(DEFAULT_FPS);
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
- const background = settings.background as Record<string, unknown>;
+ const background = asRecord(settings.background);
expect(background.type).toBe("color");
expect(background.color).toBe(DEFAULT_BACKGROUND_COLOR);
});
@@ -135,8 +136,8 @@ describe("V1 to V2 Migration", () => {
project: projectWithBlurNoIntensity,
});
- const settings = result.project.settings as Record<string, unknown>;
- const background = settings.background as Record<string, unknown>;
+ const settings = asRecord(result.project.settings);
+ const background = asRecord(settings.background);
expect(background.blurIntensity).toBe(DEFAULT_BACKGROUND_BLUR_INTENSITY);
});
@@ -183,9 +184,9 @@ describe("V1 to V2 Migration", () => {
project: projectWithTracks,
});
- const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const scenes = asRecordArray(result.project.scenes);
const mainScene = scenes[0];
- const tracks = mainScene.tracks as Array<Record<string, unknown>>;
+ const tracks = asRecordArray(mainScene.tracks);
expect(tracks.length).toBe(1);
expect(tracks[0].name).toBe("Existing Track");
});
@@ -240,9 +241,9 @@ describe("V1 to V2 Migration", () => {
context,
});
- const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const scenes = asRecordArray(result.project.scenes);
const mainScene = scenes[0];
- const tracks = mainScene.tracks as Array<Record<string, unknown>>;
+ const tracks = asRecordArray(mainScene.tracks);
expect(Array.isArray(tracks)).toBe(true);
expect(tracks).toHaveLength(1);
expect(tracks[0].type).toBe("video");
@@ -302,9 +303,9 @@ describe("V1 to V2 Migration", () => {
});
expect(result.skipped).toBe(false);
- const scenes = result.project.scenes as Array<Record<string, unknown>>;
- const tracks = scenes[0].tracks as Array<Record<string, unknown>>;
- const elements = tracks[0].elements as Array<Record<string, unknown>>;
+ const scenes = asRecordArray(result.project.scenes);
+ const tracks = asRecordArray(scenes[0].tracks);
+ const elements = asRecordArray(tracks[0].elements);
const textElement = elements[0];
expect(textElement.opacity).toBe(0.5);
expect(textElement.transform).toEqual({
diff --git a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts
index 122af2cb..1af445ac 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV15ToV16 } from "../transformers/v15-to-v16";
+import { asRecordArray } from "./helpers";
describe("V15 to V16 Migration", () => {
test("renames sticker tracks to graphic tracks", () => {
@@ -61,10 +62,10 @@ describe("V15 to V16 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(16);
- expect(
- (result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0]
- .tracks[0].type,
- ).toBe("graphic");
+ const firstTrack = asRecordArray(
+ asRecordArray(result.project.scenes)[0].tracks,
+ )[0];
+ expect(firstTrack.type).toBe("graphic");
});
test("skips projects already on v16", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts
index 352ca762..10a792ca 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV16ToV17 } from "../transformers/v16-to-v17";
+import { asRecord, asRecordArray } from "./helpers";
describe("V16 to V17 Migration", () => {
test("adds center stroke alignment to masks that do not have it", () => {
@@ -97,13 +98,13 @@ describe("V16 to V17 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(17);
- const migratedMasks = (
- ((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record<string, unknown> }> }> }> }>)[0]
- .tracks[0].elements[0].masks)
- );
+ const firstScene = asRecordArray(result.project.scenes)[0];
+ const firstTrack = asRecordArray(firstScene.tracks)[0];
+ const firstElement = asRecordArray(firstTrack.elements)[0];
+ const migratedMasks = asRecordArray(firstElement.masks);
- expect(migratedMasks[0].params.strokeAlign).toBe("center");
- expect(migratedMasks[1].params.strokeAlign).toBe("outside");
+ expect(asRecord(migratedMasks[0].params).strokeAlign).toBe("center");
+ expect(asRecord(migratedMasks[1].params).strokeAlign).toBe("outside");
});
test("skips projects already on v17", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts
index 0f679b6d..0cb30f2b 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV18ToV19 } from "../transformers/v18-to-v19";
+import { asRecord } from "./helpers";
describe("V18 to V19 Migration", () => {
test("adds canvas size mode and empty remembered custom size defaults", () => {
@@ -26,15 +27,13 @@ describe("V18 to V19 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(19);
- expect(
- (result.project.settings as Record<string, unknown>).canvasSizeMode,
- ).toBe("preset");
- expect(
- (result.project.settings as Record<string, unknown>).lastCustomCanvasSize,
- ).toBeNull();
- expect(
- (result.project.settings as Record<string, unknown>).originalCanvasSize,
- ).toEqual({ width: 1920, height: 1080 });
+ const settings = asRecord(result.project.settings);
+ expect(settings.canvasSizeMode).toBe("preset");
+ expect(settings.lastCustomCanvasSize).toBeNull();
+ expect(settings.originalCanvasSize).toEqual({
+ width: 1920,
+ height: 1080,
+ });
});
test("skips projects already on v19", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts b/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts
index 0e0cfbbe..b1bfe86f 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV19ToV20 } from "../transformers/v19-to-v20";
+import { asRecordArray } from "./helpers";
describe("V19 to V20 Migration", () => {
test("backfills source audio enabled state on video elements", () => {
@@ -82,19 +83,16 @@ describe("V19 to V20 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(20);
- expect(
- ((result.project.scenes as Array<Record<string, unknown>>)[0]
- .tracks as Array<Record<string, unknown>>)[0].elements,
- ).toEqual([
+ const scene = asRecordArray(result.project.scenes)[0];
+ const tracks = asRecordArray(scene.tracks);
+ expect(tracks[0].elements).toEqual([
expect.objectContaining({
id: "video-1",
isSourceAudioEnabled: true,
}),
]);
expect(
- (((result.project.scenes as Array<Record<string, unknown>>)[0]
- .tracks as Array<Record<string, unknown>>)[1]
- .elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
+ asRecordArray(tracks[1].elements)[0].isSourceAudioEnabled,
).toBeUndefined();
});
@@ -120,11 +118,10 @@ describe("V19 to V20 Migration", () => {
},
});
- expect(
- (((result.project.scenes as Array<Record<string, unknown>>)[0]
- .tracks as Array<Record<string, unknown>>)[0]
- .elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
- ).toBe(false);
+ const scene = asRecordArray(result.project.scenes)[0];
+ const track = asRecordArray(scene.tracks)[0];
+ const element = asRecordArray(track.elements)[0];
+ expect(element.isSourceAudioEnabled).toBe(false);
});
test("skips projects already on v20", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts
index ba8b3bfb..d18b4dd4 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts
@@ -8,6 +8,7 @@ import {
v2ProjectWithBlurBackground,
v3Project,
} from "./fixtures";
+import { asRecord } from "./helpers";
describe("V2 to V3 Migration", () => {
describe("transformProjectV2ToV3", () => {
@@ -17,14 +18,14 @@ describe("V2 to V3 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(3);
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
expect(typeof metadata.duration).toBe("number");
});
test("calculates duration from scene tracks", () => {
const result = transformProjectV2ToV3({ project: v2Project });
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
// v2Project has a video element with duration 15.5 and a text element at startTime 2 with duration 5
// Total duration should be max(15.5, 2+5) = 15.5
expect(metadata.duration).toBe(15.5);
@@ -36,7 +37,7 @@ describe("V2 to V3 Migration", () => {
});
expect(result.skipped).toBe(false);
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
// v2ProjectWithBlurBackground has a video with duration 30
expect(metadata.duration).toBe(30);
});
@@ -45,7 +46,7 @@ describe("V2 to V3 Migration", () => {
const result = transformProjectV2ToV3({ project: v2ProjectEmptyScenes });
expect(result.skipped).toBe(false);
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
expect(metadata.duration).toBe(0);
});
@@ -55,7 +56,7 @@ describe("V2 to V3 Migration", () => {
});
expect(result.skipped).toBe(false);
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
expect(metadata.duration).toBe(0);
});
@@ -91,7 +92,7 @@ describe("V2 to V3 Migration", () => {
test("preserves existing metadata fields", () => {
const result = transformProjectV2ToV3({ project: v2Project });
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
expect(metadata.id).toBe(v2Project.metadata.id);
expect(metadata.name).toBe(v2Project.metadata.name);
expect(metadata.thumbnail).toBe(v2Project.metadata.thumbnail);
@@ -122,7 +123,7 @@ describe("V2 to V3 Migration", () => {
});
expect(result.skipped).toBe(false);
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
expect(metadata.duration).toBe(0);
});
@@ -156,7 +157,7 @@ describe("V2 to V3 Migration", () => {
};
const result = transformProjectV2ToV3({ project: multiSceneProject });
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
// Duration is from main scene only, not sum of all scenes
expect(metadata.duration).toBe(10);
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts b/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts
index 3d77a775..2737bfc3 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV20ToV21 } from "../transformers/v20-to-v21";
+import { asRecord } from "./helpers";
const LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY = 50;
@@ -27,8 +28,8 @@ describe("V20 to V21 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(21);
- const settings = result.project.settings as Record<string, unknown>;
- const background = settings.background as Record<string, unknown>;
+ const settings = asRecord(result.project.settings);
+ const background = asRecord(settings.background);
expect(background.blurIntensity).toBe(500);
});
@@ -45,8 +46,8 @@ describe("V20 to V21 Migration", () => {
});
expect(result.skipped).toBe(false);
- const settings = result.project.settings as Record<string, unknown>;
- const background = settings.background as Record<string, unknown>;
+ const settings = asRecord(result.project.settings);
+ const background = asRecord(settings.background);
expect(background.blurIntensity).toBe(
LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY,
);
@@ -65,7 +66,7 @@ describe("V20 to V21 Migration", () => {
});
expect(result.skipped).toBe(false);
- const settings = result.project.settings as Record<string, unknown>;
+ const settings = asRecord(result.project.settings);
expect(settings.background).toEqual({ type: "color", color: "#000000" });
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts
index 18b815b0..76d9c0e1 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV21ToV22 } from "../transformers/v21-to-v22";
+import { asRecord, asRecordArray } from "./helpers";
describe("V21 to V22 Migration", () => {
test("migrates legacy animation channels to bindings and component channels", () => {
@@ -77,12 +78,12 @@ describe("V21 to V22 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(22);
- const scenes = result.project.scenes as Array<Record<string, unknown>>;
- const tracks = scenes[0].tracks as Array<Record<string, unknown>>;
- const elements = tracks[0].elements as Array<Record<string, unknown>>;
- const animations = elements[0].animations as Record<string, unknown>;
- const bindings = animations.bindings as Record<string, Record<string, unknown>>;
- const channels = animations.channels as Record<string, Record<string, unknown>>;
+ const scenes = asRecordArray(result.project.scenes);
+ const tracks = asRecordArray(scenes[0].tracks);
+ const elements = asRecordArray(tracks[0].elements);
+ const animations = asRecord(elements[0].animations);
+ const bindings = asRecord(animations.bindings);
+ const channels = asRecord(animations.channels);
expect(bindings.opacity).toEqual({
path: "opacity",
diff --git a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts
index 5b764b48..e229ecbc 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV22ToV23 } from "../transformers/v22-to-v23";
+import { asRecord, asRecordArray } from "./helpers";
describe("V22 to V23 Migration", () => {
test("converts project time values from seconds to ticks and fps to a frame-rate object", () => {
@@ -101,20 +102,17 @@ describe("V22 to V23 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(23);
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
expect(metadata.duration).toBe(1_860_000);
- const settings = result.project.settings as Record<string, unknown>;
+ const settings = asRecord(result.project.settings);
expect(settings.fps).toEqual({ numerator: 30_000, denominator: 1_001 });
- const timelineViewState = result.project.timelineViewState as Record<
- string,
- unknown
- >;
+ const timelineViewState = asRecord(result.project.timelineViewState);
expect(timelineViewState.playheadTime).toBe(150_000);
expect(timelineViewState.scrollLeft).toBe(120);
- const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const scenes = asRecordArray(result.project.scenes);
const scene = scenes[0];
expect(scene.bookmarks).toEqual([
{
@@ -126,8 +124,8 @@ describe("V22 to V23 Migration", () => {
{ time: 540_000 },
]);
- const tracks = scene.tracks as Array<Record<string, unknown>>;
- const elements = tracks[0].elements as Array<Record<string, unknown>>;
+ const tracks = asRecordArray(scene.tracks);
+ const elements = asRecordArray(tracks[0].elements);
const element = elements[0];
expect(element.startTime).toBe(150_000);
expect(element.duration).toBe(660_000);
@@ -135,8 +133,8 @@ describe("V22 to V23 Migration", () => {
expect(element.trimEnd).toBe(60_000);
expect(element.sourceDuration).toBe(750_000);
- const animations = element.animations as Record<string, unknown>;
- const channels = animations.channels as Record<string, Record<string, unknown>>;
+ const animations = asRecord(element.animations);
+ const channels = asRecord(animations.channels);
expect(channels["opacity:value"]).toEqual({
kind: "scalar",
keys: [
diff --git a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts
index 730d958d..5e16e886 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV26ToV27 } from "../transformers/v26-to-v27";
+import { asRecord, asRecordArray } from "./helpers";
describe("V26 to V27 Migration", () => {
test("converts custom mask paths from JSON strings to typed point arrays", () => {
@@ -59,13 +60,13 @@ describe("V26 to V27 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(27);
- const scenes = result.project.scenes as Array<Record<string, unknown>>;
- const tracks = scenes[0].tracks as Record<string, unknown>;
- const mainTrack = tracks.main as Record<string, unknown>;
- const elements = mainTrack.elements as Array<Record<string, unknown>>;
- const masks = elements[0].masks as Array<Record<string, unknown>>;
- const customParams = masks[0].params as Record<string, unknown>;
- const rectangleParams = masks[1].params as Record<string, unknown>;
+ const scenes = asRecordArray(result.project.scenes);
+ const tracks = asRecord(scenes[0].tracks);
+ const mainTrack = asRecord(tracks.main);
+ const elements = asRecordArray(mainTrack.elements);
+ const masks = asRecordArray(elements[0].masks);
+ const customParams = asRecord(masks[0].params);
+ const rectangleParams = asRecord(masks[1].params);
expect(customParams.path).toEqual([
{
diff --git a/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts
new file mode 100644
index 00000000..cb09e07d
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts
@@ -0,0 +1,252 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV27ToV28 } from "../transformers/v27-to-v28";
+import { asRecord, asRecordArray } from "./helpers";
+
+describe("V27 to V28 Migration", () => {
+ test("rounds persisted media-time floats back to integer ticks", () => {
+ const result = transformProjectV27ToV28({
+ project: {
+ id: "project-v27-float-time",
+ version: 27,
+ metadata: {
+ id: "project-v27-float-time",
+ name: "Project",
+ duration: 2_152_466.3677130044,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ settings: {
+ fps: { numerator: 30, denominator: 1 },
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ timelineViewState: {
+ zoomLevel: 1.25,
+ scrollLeft: 120,
+ playheadTime: 301_234.8,
+ },
+ scenes: [
+ {
+ id: "scene-1",
+ name: "Scene 1",
+ isMain: true,
+ bookmarks: [
+ {
+ time: 120_000.4,
+ duration: 60_000.6,
+ note: "Marker",
+ color: "#ff0000",
+ },
+ ],
+ tracks: {
+ main: {
+ id: "main-track",
+ type: "video",
+ name: "Main",
+ muted: false,
+ hidden: false,
+ elements: [
+ {
+ id: "element-1",
+ type: "video",
+ name: "Clip",
+ startTime: 300_000.49,
+ duration: 2_152_466.3677130044,
+ trimStart: 30_000.2,
+ trimEnd: 15_000.7,
+ sourceDuration: 2_197_467.6,
+ mediaId: "media-1",
+ transform: {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ },
+ opacity: 1,
+ animations: {
+ channels: {
+ opacity: {
+ kind: "scalar",
+ keys: [
+ {
+ id: "key-1",
+ time: 1_000.6,
+ value: 1,
+ segmentToNext: "bezier",
+ tangentMode: "flat",
+ leftHandle: { dt: -120.6, dv: 0 },
+ rightHandle: { dt: 60.4, dv: 0 },
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+ },
+ overlay: [],
+ audio: [],
+ },
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(28);
+
+ const metadata = asRecord(result.project.metadata);
+ expect(metadata.duration).toBe(2_152_466);
+
+ const timelineViewState = asRecord(result.project.timelineViewState);
+ expect(timelineViewState.playheadTime).toBe(301_235);
+ expect(timelineViewState.zoomLevel).toBe(1.25);
+ expect(timelineViewState.scrollLeft).toBe(120);
+
+ const scenes = asRecordArray(result.project.scenes);
+ const scene = scenes[0];
+ expect(scene.bookmarks).toEqual([
+ {
+ time: 120_000,
+ duration: 60_001,
+ note: "Marker",
+ color: "#ff0000",
+ },
+ ]);
+
+ const tracks = asRecord(scene.tracks);
+ const mainTrack = asRecord(tracks.main);
+ const elements = asRecordArray(mainTrack.elements);
+ const element = elements[0];
+ expect(element.startTime).toBe(300_000);
+ expect(element.duration).toBe(2_152_466);
+ expect(element.trimStart).toBe(30_000);
+ expect(element.trimEnd).toBe(15_001);
+ expect(element.sourceDuration).toBe(2_197_468);
+
+ const animations = asRecord(element.animations);
+ const channels = asRecord(animations.channels);
+ const opacityChannel = asRecord(channels.opacity);
+ expect(opacityChannel.keys).toEqual([
+ {
+ id: "key-1",
+ time: 1_001,
+ value: 1,
+ segmentToNext: "bezier",
+ tangentMode: "flat",
+ leftHandle: { dt: -121, dv: 0 },
+ rightHandle: { dt: 60, dv: 0 },
+ },
+ ]);
+ });
+
+ test("keeps already-integer media-time values unchanged", () => {
+ const result = transformProjectV27ToV28({
+ project: {
+ id: "project-v27-integer-time",
+ version: 27,
+ metadata: {
+ id: "project-v27-integer-time",
+ name: "Project",
+ duration: 120_000,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ timelineViewState: {
+ zoomLevel: 2,
+ scrollLeft: 300,
+ playheadTime: 30_000,
+ },
+ scenes: [
+ {
+ id: "scene-1",
+ bookmarks: [{ time: 60_000, duration: 15_000 }],
+ tracks: {
+ main: {
+ id: "main-track",
+ type: "video",
+ name: "Main",
+ muted: false,
+ hidden: false,
+ elements: [
+ {
+ id: "element-1",
+ type: "video",
+ name: "Clip",
+ startTime: 10_000,
+ duration: 20_000,
+ trimStart: 1_000,
+ trimEnd: 2_000,
+ sourceDuration: 23_000,
+ mediaId: "media-1",
+ transform: {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ },
+ opacity: 1,
+ },
+ ],
+ },
+ overlay: [],
+ audio: [],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(28);
+
+ const metadata = asRecord(result.project.metadata);
+ expect(metadata.duration).toBe(120_000);
+
+ const timelineViewState = asRecord(result.project.timelineViewState);
+ expect(timelineViewState).toEqual({
+ zoomLevel: 2,
+ scrollLeft: 300,
+ playheadTime: 30_000,
+ });
+
+ const scenes = asRecordArray(result.project.scenes);
+ const scene = scenes[0];
+ expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]);
+
+ const tracks = asRecord(scene.tracks);
+ const mainTrack = asRecord(tracks.main);
+ const element = asRecordArray(mainTrack.elements)[0];
+ expect(element.startTime).toBe(10_000);
+ expect(element.duration).toBe(20_000);
+ expect(element.trimStart).toBe(1_000);
+ expect(element.trimEnd).toBe(2_000);
+ expect(element.sourceDuration).toBe(23_000);
+ });
+
+ test("skips projects already on v28", () => {
+ const result = transformProjectV27ToV28({
+ project: {
+ id: "project-v28",
+ version: 28,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v28");
+ });
+
+ test("skips projects not on v27", () => {
+ const result = transformProjectV27ToV28({
+ project: {
+ id: "project-v26",
+ version: 26,
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("not v27");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts b/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts
new file mode 100644
index 00000000..38a806e1
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV28ToV29 } from "../transformers/v28-to-v29";
+import { asRecord, asRecordArray } from "./helpers";
+
+describe("V28 to V29 Migration", () => {
+ test("copies built-in element fields into params without deleting source fields", () => {
+ const result = transformProjectV28ToV29({
+ project: {
+ id: "project-v28-builtins",
+ version: 28,
+ scenes: [
+ {
+ id: "scene-1",
+ tracks: {
+ main: {
+ id: "main",
+ type: "video",
+ elements: [
+ {
+ id: "video-1",
+ type: "video",
+ name: "Video",
+ transform: {
+ position: { x: 12, y: -4 },
+ scaleX: 1.25,
+ scaleY: 0.75,
+ rotate: 9,
+ },
+ opacity: 0.5,
+ blendMode: "multiply",
+ volume: -6,
+ muted: true,
+ },
+ ],
+ },
+ overlay: [
+ {
+ id: "text-track",
+ type: "text",
+ elements: [
+ {
+ id: "text-1",
+ type: "text",
+ name: "Text",
+ content: "Hello",
+ fontSize: 20,
+ fontFamily: "Inter",
+ color: "#eeeeee",
+ textAlign: "left",
+ fontWeight: "bold",
+ fontStyle: "italic",
+ textDecoration: "underline",
+ letterSpacing: 1.5,
+ lineHeight: 1.4,
+ background: {
+ enabled: true,
+ color: "#111111",
+ paddingX: 10,
+ paddingY: 12,
+ },
+ transform: {
+ position: { x: 1, y: 2 },
+ scaleX: 1,
+ scaleY: 1,
+ rotate: 0,
+ },
+ opacity: 1,
+ },
+ ],
+ },
+ ],
+ audio: [],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(29);
+
+ const scene = asRecordArray(result.project.scenes)[0];
+ const tracks = asRecord(scene.tracks);
+ const main = asRecord(tracks.main);
+ const video = asRecordArray(main.elements)[0];
+ expect(video.transform).toEqual({
+ position: { x: 12, y: -4 },
+ scaleX: 1.25,
+ scaleY: 0.75,
+ rotate: 9,
+ });
+ expect(video.opacity).toBe(0.5);
+ expect(video.volume).toBe(-6);
+ expect(video.muted).toBe(true);
+ expect(video.params).toEqual({
+ "transform.positionX": 12,
+ "transform.positionY": -4,
+ "transform.scaleX": 1.25,
+ "transform.scaleY": 0.75,
+ "transform.rotate": 9,
+ opacity: 0.5,
+ blendMode: "multiply",
+ volume: -6,
+ muted: true,
+ });
+
+ const overlay = asRecordArray(tracks.overlay)[0];
+ const text = asRecordArray(asRecord(overlay).elements)[0];
+ expect(text.content).toBe("Hello");
+ expect(text.background).toEqual({
+ enabled: true,
+ color: "#111111",
+ paddingX: 10,
+ paddingY: 12,
+ });
+ expect(text.params).toMatchObject({
+ content: "Hello",
+ fontSize: 20,
+ fontFamily: "Inter",
+ color: "#eeeeee",
+ textAlign: "left",
+ fontWeight: "bold",
+ fontStyle: "italic",
+ textDecoration: "underline",
+ letterSpacing: 1.5,
+ lineHeight: 1.4,
+ "background.enabled": true,
+ "background.color": "#111111",
+ "background.paddingX": 10,
+ "background.paddingY": 12,
+ "transform.positionX": 1,
+ "transform.positionY": 2,
+ });
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts
new file mode 100644
index 00000000..c68f8dce
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV29ToV30 } from "../transformers/v29-to-v30";
+import { asRecord, asRecordArray } from "./helpers";
+
+describe("V29 to V30 Migration", () => {
+ test("flattens animation bindings and channels by path", () => {
+ const result = transformProjectV29ToV30({
+ project: {
+ id: "project-v29-animations",
+ version: 29,
+ scenes: [
+ {
+ id: "scene-1",
+ tracks: {
+ main: {
+ id: "main",
+ type: "video",
+ elements: [
+ {
+ id: "text-1",
+ type: "text",
+ animations: {
+ bindings: {
+ opacity: {
+ path: "opacity",
+ kind: "number",
+ components: [
+ { key: "value", channelId: "opacity:value" },
+ ],
+ },
+ color: {
+ path: "color",
+ kind: "color",
+ colorSpace: "srgb-linear",
+ components: [
+ { key: "r", channelId: "color:r" },
+ { key: "g", channelId: "color:g" },
+ { key: "b", channelId: "color:b" },
+ { key: "a", channelId: "color:a" },
+ ],
+ },
+ "params.label": {
+ path: "params.label",
+ kind: "discrete",
+ components: [
+ {
+ key: "value",
+ channelId: "params.label:value",
+ },
+ ],
+ },
+ },
+ channels: {
+ "opacity:value": {
+ kind: "scalar",
+ keys: [{ id: "o1", time: 0, value: 0.5 }],
+ },
+ "color:r": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 1 }],
+ },
+ "color:g": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 0.5 }],
+ },
+ "color:b": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 0 }],
+ },
+ "color:a": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 1 }],
+ },
+ "params.label:value": {
+ kind: "discrete",
+ keys: [{ id: "d1", time: 0, value: "Title" }],
+ },
+ },
+ },
+ },
+ ],
+ },
+ overlay: [],
+ audio: [],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(30);
+ const scene = asRecordArray(result.project.scenes)[0];
+ const tracks = asRecord(scene.tracks);
+ const main = asRecord(tracks.main);
+ const element = asRecordArray(main.elements)[0];
+ const animations = asRecord(element.animations);
+ expect(animations.bindings).toBeDefined();
+ expect(animations.channels).toBeDefined();
+ expect(animations).toMatchObject({
+ opacity: {
+ keys: [{ id: "o1", time: 0, value: 0.5 }],
+ },
+ color: {
+ r: { keys: [{ id: "c1", time: 0, value: 1 }] },
+ g: { keys: [{ id: "c1", time: 0, value: 0.5 }] },
+ b: { keys: [{ id: "c1", time: 0, value: 0 }] },
+ a: { keys: [{ id: "c1", time: 0, value: 1 }] },
+ },
+ "params.label": {
+ keys: [{ id: "d1", time: 0, value: "Title" }],
+ },
+ });
+ });
+
+ test("leaves elements without animations unchanged", () => {
+ const result = transformProjectV29ToV30({
+ project: {
+ id: "project-v29-no-animations",
+ version: 29,
+ scenes: [
+ {
+ tracks: {
+ main: {
+ elements: [{ id: "image-1", type: "image" }],
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ const scene = asRecordArray(result.project.scenes)[0];
+ const tracks = asRecord(scene.tracks);
+ const main = asRecord(tracks.main);
+ const element = asRecordArray(main.elements)[0];
+ expect(element).toEqual({ id: "image-1", type: "image" });
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts
index 6c2d6597..e5ad7e34 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV3ToV4 } from "../transformers/v3-to-v4";
import { v3Project } from "./fixtures";
+import { asRecordArray } from "./helpers";
describe("V3 to V4 Migration", () => {
test("normalizes legacy text fontWeight values", () => {
@@ -54,15 +55,9 @@ describe("V3 to V4 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(4);
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.fontWeight).toBe("700");
});
@@ -104,15 +99,9 @@ describe("V3 to V4 Migration", () => {
};
const result = transformProjectV3ToV4({ project: projectWithoutTextTrack });
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.iconName).toBe("mdi:home");
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts b/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts
new file mode 100644
index 00000000..65a1ffed
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV30ToV31 } from "../transformers/v30-to-v31";
+import { asRecord, asRecordArray } from "./helpers";
+
+describe("V30 to V31 Migration", () => {
+ test("renames custom masks to freeform without changing params", () => {
+ const params = {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ path: [{ id: "p1", x: 0, y: 0, inX: 0, inY: 0, outX: 0, outY: 0 }],
+ closed: true,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ };
+
+ const result = transformProjectV30ToV31({
+ project: {
+ id: "project-v30-freeform",
+ version: 30,
+ scenes: [
+ {
+ tracks: {
+ main: {
+ elements: [
+ {
+ id: "image-1",
+ type: "image",
+ masks: [{ id: "mask-1", type: "custom", params }],
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(31);
+ const scene = asRecordArray(result.project.scenes)[0];
+ const tracks = asRecord(scene.tracks);
+ const main = asRecord(tracks.main);
+ const element = asRecordArray(main.elements)[0];
+ const mask = asRecordArray(asRecord(element).masks)[0];
+ expect(mask).toMatchObject({
+ id: "mask-1",
+ type: "freeform",
+ params,
+ });
+ expect(asRecord(mask).legacyType).toBeUndefined();
+ });
+
+ test("leaves a type:freeform mask in a v30 project unchanged", () => {
+ const result = transformProjectV30ToV31({
+ project: {
+ id: "project-v30-already-freeform",
+ version: 30,
+ scenes: [
+ {
+ tracks: {
+ main: {
+ elements: [
+ {
+ id: "elem-1",
+ masks: [{ id: "m1", type: "freeform", params: {} }],
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ const scene = asRecordArray(result.project.scenes)[0];
+ const main = asRecord(asRecord(scene.tracks).main);
+ const element = asRecordArray(main.elements)[0];
+ expect(asRecordArray(asRecord(element).masks)[0]).toMatchObject({
+ id: "m1",
+ type: "freeform",
+ });
+ });
+
+ test("leaves elements without a masks array unchanged", () => {
+ const result = transformProjectV30ToV31({
+ project: {
+ id: "project-v30-no-masks",
+ version: 30,
+ scenes: [
+ {
+ tracks: {
+ main: {
+ elements: [{ id: "elem-1", type: "video" }],
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ const scene = asRecordArray(result.project.scenes)[0];
+ const main = asRecord(asRecord(scene.tracks).main);
+ const element = asRecordArray(main.elements)[0];
+ expect(element).toMatchObject({ id: "elem-1", type: "video" });
+ });
+
+ test("skips a project that is already v31", () => {
+ const project = { id: "p1", version: 31, scenes: [] };
+ const result = transformProjectV30ToV31({ project });
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v31");
+ expect(result.project).toBe(project);
+ });
+
+ test("skips a project that is not v30", () => {
+ const project = { id: "p1", version: 29, scenes: [] };
+ const result = transformProjectV30ToV31({ project });
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("not v30");
+ expect(result.project).toBe(project);
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts
index 95386137..16e9e7ca 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV4ToV5 } from "../transformers/v4-to-v5";
import { v3Project } from "./fixtures";
+import { asRecordArray } from "./helpers";
describe("V4 to V5 Migration", () => {
test("migrates sticker iconName to stickerId and removes legacy color", () => {
@@ -48,15 +49,9 @@ describe("V4 to V5 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(5);
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.stickerId).toBe("icons:mdi:home");
expect("iconName" in migratedElement).toBe(false);
@@ -101,15 +96,9 @@ describe("V4 to V5 Migration", () => {
};
const result = transformProjectV4ToV5({ project: projectWithStickerId });
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.stickerId).toBe("flags:AD");
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
index b05d1837..8fd267fd 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
import { v5Project } from "./fixtures";
+import { asRecordArray } from "./helpers";
describe("V5 to V6 Migration", () => {
test("converts number bookmarks to Bookmark objects", async () => {
@@ -13,19 +14,13 @@ describe("V5 to V6 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(6);
- const mainScene = (
- result.project.scenes as Array<{ bookmarks: unknown[] }>
- )[0];
- expect(mainScene.bookmarks).toEqual([
+ const scenes = asRecordArray(result.project.scenes);
+ expect(scenes[0].bookmarks).toEqual([
{ time: 2.0 },
{ time: 5.5 },
{ time: 12.0 },
]);
-
- const introScene = (
- result.project.scenes as Array<{ bookmarks: unknown[] }>
- )[1];
- expect(introScene.bookmarks).toEqual([]);
+ expect(scenes[1].bookmarks).toEqual([]);
});
test("skips projects that are already v6", () => {
@@ -83,9 +78,7 @@ describe("V5 to V6 Migration", () => {
});
expect(result.skipped).toBe(false);
- const mainScene = (
- result.project.scenes as Array<{ bookmarks: unknown[] }>
- )[0];
+ const mainScene = asRecordArray(result.project.scenes)[0];
expect(mainScene.bookmarks).toEqual([
{ time: 1, note: "Intro", color: "#ef4444" },
{ time: 5.5, duration: 2 },
diff --git a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts
index aa79a6ea..ce8e9bd3 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
+import { asRecord, asRecordArray } from "./helpers";
const v8ProjectWithText = {
id: "project-v8-text",
@@ -70,16 +71,16 @@ describe("V8 to V9 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(9);
- const track = (
- result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
- )[0].tracks[0];
- const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
+ const track = asRecordArray(asRecordArray(result.project.scenes)[0].tracks)[0];
+ const elements = asRecordArray(track.elements);
+ const background0 = asRecord(elements[0].background);
+ const background1 = asRecord(elements[1].background);
- expect(elements[0].background.enabled).toBe(true);
- expect(elements[0].background.color).toBe("#ff0000");
+ expect(background0.enabled).toBe(true);
+ expect(background0.color).toBe("#ff0000");
- expect(elements[1].background.enabled).toBe(false);
- expect(elements[1].background.color).toBe("transparent");
+ expect(background1.enabled).toBe(false);
+ expect(background1.color).toBe("transparent");
});
test("preserves existing background.enabled if already present", () => {
@@ -87,7 +88,7 @@ describe("V8 to V9 Migration", () => {
...v8ProjectWithText,
scenes: [
{
- ...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
+ ...asRecordArray(v8ProjectWithText.scenes)[0],
tracks: [
{
id: "track-text",
@@ -116,10 +117,9 @@ describe("V8 to V9 Migration", () => {
const result = transformProjectV8ToV9({ project: projectWithEnabled });
expect(result.skipped).toBe(false);
- const elements = (
- result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
- )[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
- expect(elements[0].background.enabled).toBe(false);
+ const track = asRecordArray(asRecordArray(result.project.scenes)[0].tracks)[0];
+ const elements = asRecordArray(track.elements);
+ expect(asRecord(elements[0].background).enabled).toBe(false);
});
test("skips non-text elements and tracks", () => {
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index b290b713..cbc35bb3 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -26,10 +26,14 @@ import { V23toV24Migration } from "./v23-to-v24";
import { V24toV25Migration } from "./v24-to-v25";
import { V25toV26Migration } from "./v25-to-v26";
import { V26toV27Migration } from "./v26-to-v27";
+import { V27toV28Migration } from "./v27-to-v28";
+import { V28toV29Migration } from "./v28-to-v29";
+import { V29toV30Migration } from "./v29-to-v30";
+import { V30toV31Migration } from "./v30-to-v31";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 27;
+export const CURRENT_PROJECT_VERSION = 31;
export const migrations = [
new V0toV1Migration(),
@@ -59,4 +63,8 @@ export const migrations = [
new V24toV25Migration(),
new V25toV26Migration(),
new V26toV27Migration(),
+ new V27toV28Migration(),
+ new V28toV29Migration(),
+ new V29toV30Migration(),
+ new V30toV31Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts
index c1ae894f..ace19354 100644
--- a/apps/web/src/services/storage/migrations/transformers/index.ts
+++ b/apps/web/src/services/storage/migrations/transformers/index.ts
@@ -4,4 +4,5 @@ export { transformProjectV2ToV3 } from "./v2-to-v3";
export { transformProjectV3ToV4 } from "./v3-to-v4";
export { transformProjectV4ToV5 } from "./v4-to-v5";
export { transformProjectV17ToV18 } from "./v17-to-v18";
+export { transformProjectV28ToV29 } from "./v28-to-v29";
export type { MigrationResult, ProjectRecord } from "./types";
diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
index 065a6b34..e9e23c4e 100644
--- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts
@@ -19,34 +19,6 @@ const EMPTY_V1_TO_V2_CONTEXT: V1ToV2Context = {
mediaTypesById: {},
};
-interface LegacyMediaElement {
- type: "media";
- mediaId: string;
- muted?: boolean;
- [key: string]: unknown;
-}
-
-interface LegacyTextElement {
- type: "text";
- x: number;
- y: number;
- rotation: number;
- opacity: number;
- [key: string]: unknown;
-}
-
-interface LegacyAudioElement {
- type: "audio";
- mediaId: string;
- [key: string]: unknown;
-}
-
-interface LegacyMediaTrack {
- type: "media";
- elements: unknown[];
- [key: string]: unknown;
-}
-
interface V2Transform {
scale: number;
position: { x: number; y: number };
@@ -315,7 +287,7 @@ function transformTracks({
const isMain = !isFirstVideoTrackFound;
isFirstVideoTrackFound = true;
const videoTrack = transformMediaTrack({
- track: track as LegacyMediaTrack,
+ track,
context,
isMain,
});
@@ -343,7 +315,7 @@ function transformMediaTrack({
context,
isMain,
}: {
- track: LegacyMediaTrack;
+ track: Record<string, unknown>;
context: V1ToV2Context;
isMain: boolean;
}): V2VideoTrack {
@@ -354,8 +326,7 @@ function transformMediaTrack({
return null;
}
- const mediaElement = element as LegacyMediaElement;
- const mediaId = getStringValue({ value: mediaElement.mediaId });
+ const mediaId = getStringValue({ value: element.mediaId });
if (!mediaId) {
return null;
}
@@ -372,7 +343,7 @@ function transformMediaTrack({
rotate: 0,
};
- const muted = mediaElement.muted === true;
+ const muted = element.muted === true;
if (mediaType === "image") {
const imageElement: V2ImageElement = {
@@ -442,15 +413,14 @@ function transformTextTrack({
return null;
}
- const textElement = element as LegacyTextElement;
- const x = getNumberValue({ value: textElement.x, fallback: 0 });
- const y = getNumberValue({ value: textElement.y, fallback: 0 });
+ const x = getNumberValue({ value: element.x, fallback: 0 });
+ const y = getNumberValue({ value: element.y, fallback: 0 });
const rotation = getNumberValue({
- value: textElement.rotation,
+ value: element.rotation,
fallback: 0,
});
const opacity = getNumberValue({
- value: textElement.opacity,
+ value: element.opacity,
fallback: 1,
});
@@ -464,23 +434,23 @@ function transformTextTrack({
id: getStringValue({ value: element.id, fallback: "" }),
name: getStringValue({ value: element.name, fallback: "" }),
type: "text",
- content: getStringValue({ value: textElement.content, fallback: "" }),
+ content: getStringValue({ value: element.content, fallback: "" }),
fontSize: getNumberValue({
- value: textElement.fontSize,
+ value: element.fontSize,
fallback: 16,
}),
fontFamily: getStringValue({
- value: textElement.fontFamily,
+ value: element.fontFamily,
fallback: "Arial",
}),
color: getStringValue({
- value: textElement.color,
+ value: element.color,
fallback: "#000000",
}),
background: {
enabled: false,
color: getStringValue({
- value: textElement.backgroundColor,
+ value: element.backgroundColor,
fallback: "transparent",
}),
cornerRadius: 0,
@@ -489,22 +459,26 @@ function transformTextTrack({
offsetX: 0,
offsetY: 0,
},
- textAlign: (getStringValue({
- value: textElement.textAlign,
+ textAlign: parseEnum({
+ value: element.textAlign,
+ allowed: ["left", "center", "right"] as const,
fallback: "left",
- }) || "left") as "left" | "center" | "right",
- fontWeight: (getStringValue({
- value: textElement.fontWeight,
+ }),
+ fontWeight: parseEnum({
+ value: element.fontWeight,
+ allowed: ["normal", "bold"] as const,
fallback: "normal",
- }) || "normal") as "normal" | "bold",
- fontStyle: (getStringValue({
- value: textElement.fontStyle,
+ }),
+ fontStyle: parseEnum({
+ value: element.fontStyle,
+ allowed: ["normal", "italic"] as const,
fallback: "normal",
- }) || "normal") as "normal" | "italic",
- textDecoration: (getStringValue({
- value: textElement.textDecoration,
+ }),
+ textDecoration: parseEnum({
+ value: element.textDecoration,
+ allowed: ["none", "underline", "line-through"] as const,
fallback: "none",
- }) || "none") as "none" | "underline" | "line-through",
+ }),
hidden: false,
transform,
opacity,
@@ -538,8 +512,7 @@ function transformAudioTrack({
return null;
}
- const audioElement = element as LegacyAudioElement;
- const mediaId = getStringValue({ value: audioElement.mediaId });
+ const mediaId = getStringValue({ value: element.mediaId });
if (!mediaId) {
return null;
}
@@ -762,6 +735,23 @@ function getStringValue({
return fallback;
}
+function parseEnum<T extends string>({
+ value,
+ allowed,
+ fallback,
+}: {
+ value: unknown;
+ allowed: readonly T[];
+ fallback: T;
+}): T {
+ for (const candidate of allowed) {
+ if (value === candidate) {
+ return candidate;
+ }
+ }
+ return fallback;
+}
+
function normalizeDateString({ value }: { value: unknown }): string {
if (value instanceof Date) {
return value.toISOString();
diff --git a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts
index 89dde38e..6e152adb 100644
--- a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts
@@ -94,10 +94,7 @@ function migrateSplitMask({ mask }: { mask: unknown }): unknown {
const x = (position - 0.5) * Math.cos(angleRad);
const y = (position - 0.5) * Math.sin(angleRad);
- const { position: _removed, ...restParams } = params as Record<
- string,
- unknown
- > & { position: unknown };
+ const { position: _removed, ...restParams } = params;
return {
...mask,
diff --git a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts
index 3b118ee7..ccebb980 100644
--- a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts
@@ -1,7 +1,7 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
-interface CustomMaskPathPoint {
+interface FreeformPathPoint {
id: string;
x: number;
y: number;
@@ -11,35 +11,34 @@ interface CustomMaskPathPoint {
outY: number;
}
-function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
- if (!value || typeof value !== "object") {
+function isFreeformPathPoint(value: unknown): value is FreeformPathPoint {
+ if (!isRecord(value)) {
return false;
}
- const candidate = value as Record<string, unknown>;
return (
- typeof candidate.id === "string" &&
- typeof candidate.x === "number" &&
- typeof candidate.y === "number" &&
- typeof candidate.inX === "number" &&
- typeof candidate.inY === "number" &&
- typeof candidate.outX === "number" &&
- typeof candidate.outY === "number"
+ typeof value.id === "string" &&
+ typeof value.x === "number" &&
+ typeof value.y === "number" &&
+ typeof value.inX === "number" &&
+ typeof value.inY === "number" &&
+ typeof value.outX === "number" &&
+ typeof value.outY === "number"
);
}
-function parseCustomMaskPath({
+function parseFreeformPath({
path,
}: {
path: string;
-}): CustomMaskPathPoint[] {
+}): FreeformPathPoint[] {
if (!path) {
return [];
}
try {
const parsed = JSON.parse(path);
- return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : [];
+ return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : [];
} catch {
return [];
}
@@ -151,7 +150,7 @@ function migrateMask({ mask }: { mask: unknown }): unknown {
...mask,
params: {
...mask.params,
- path: parseCustomMaskPath({ path }),
+ path: parseFreeformPath({ path }),
},
};
}
diff --git a/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts
new file mode 100644
index 00000000..fc8d8432
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts
@@ -0,0 +1,228 @@
+import { roundMediaTime } from "@/wasm";
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV27ToV28({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 28) {
+ return { project, skipped: true, reason: "already v28" };
+ }
+ if (version !== 27) {
+ return { project, skipped: true, reason: "not v27" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 28,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const nextProject = { ...project };
+
+ if (isRecord(project.metadata)) {
+ nextProject.metadata = migrateTimeFields({
+ record: project.metadata,
+ keys: ["duration"],
+ });
+ }
+
+ if (isRecord(project.timelineViewState)) {
+ nextProject.timelineViewState = migrateTimeFields({
+ record: project.timelineViewState,
+ keys: ["playheadTime"],
+ });
+ }
+
+ if (Array.isArray(project.scenes)) {
+ nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
+ }
+
+ return nextProject;
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const nextScene = { ...scene };
+
+ if (Array.isArray(scene.bookmarks)) {
+ nextScene.bookmarks = scene.bookmarks.map((bookmark) =>
+ migrateBookmark({ bookmark }),
+ );
+ }
+
+ if (isRecord(scene.tracks)) {
+ nextScene.tracks = migrateTracks({ tracks: scene.tracks });
+ }
+
+ return nextScene;
+}
+
+function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
+ const nextTracks = { ...tracks };
+
+ if (isRecord(tracks.main)) {
+ nextTracks.main = migrateTrack({ track: tracks.main });
+ }
+
+ if (Array.isArray(tracks.overlay)) {
+ nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
+ }
+
+ if (Array.isArray(tracks.audio)) {
+ nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
+ }
+
+ return nextTracks;
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track) || !Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) {
+ return element;
+ }
+
+ const nextElement = migrateTimeFields({
+ record: element,
+ keys: ["duration", "startTime", "trimStart", "trimEnd", "sourceDuration"],
+ });
+
+ if (isRecord(element.animations)) {
+ nextElement.animations = migrateAnimations({
+ animations: element.animations,
+ });
+ }
+
+ return nextElement;
+}
+
+function migrateAnimations({
+ animations,
+}: {
+ animations: ProjectRecord;
+}): ProjectRecord {
+ if (!isRecord(animations.channels)) {
+ return animations;
+ }
+
+ return {
+ ...animations,
+ channels: Object.fromEntries(
+ Object.entries(animations.channels).map(([channelId, channel]) => [
+ channelId,
+ migrateAnimationChannel({ channel }),
+ ]),
+ ),
+ };
+}
+
+function migrateAnimationChannel({ channel }: { channel: unknown }): unknown {
+ if (!isRecord(channel) || !Array.isArray(channel.keys)) {
+ return channel;
+ }
+
+ return {
+ ...channel,
+ keys: channel.keys.map((keyframe) => migrateAnimationKeyframe({ keyframe })),
+ };
+}
+
+function migrateAnimationKeyframe({
+ keyframe,
+}: {
+ keyframe: unknown;
+}): unknown {
+ if (!isRecord(keyframe)) {
+ return keyframe;
+ }
+
+ const nextKeyframe = migrateTimeFields({
+ record: keyframe,
+ keys: ["time"],
+ });
+
+ if (isRecord(keyframe.leftHandle)) {
+ nextKeyframe.leftHandle = migrateTimeFields({
+ record: keyframe.leftHandle,
+ keys: ["dt"],
+ });
+ }
+
+ if (isRecord(keyframe.rightHandle)) {
+ nextKeyframe.rightHandle = migrateTimeFields({
+ record: keyframe.rightHandle,
+ keys: ["dt"],
+ });
+ }
+
+ return nextKeyframe;
+}
+
+function migrateBookmark({ bookmark }: { bookmark: unknown }): unknown {
+ if (!isRecord(bookmark)) {
+ return bookmark;
+ }
+
+ return migrateTimeFields({
+ record: bookmark,
+ keys: ["time", "duration"],
+ });
+}
+
+function migrateTimeFields({
+ record,
+ keys,
+}: {
+ record: ProjectRecord;
+ keys: string[];
+}): ProjectRecord {
+ const nextRecord = { ...record };
+
+ for (const key of keys) {
+ if (!(key in record)) {
+ continue;
+ }
+
+ nextRecord[key] = normalizeMediaTimeValue({ value: record[key] });
+ }
+
+ return nextRecord;
+}
+
+function normalizeMediaTimeValue({ value }: { value: unknown }): unknown {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return value;
+ }
+ return roundMediaTime({ time: value });
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts b/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts
new file mode 100644
index 00000000..4ce048f6
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts
@@ -0,0 +1,226 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV28ToV29({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 29) {
+ return { project, skipped: true, reason: "already v29" };
+ }
+ if (version !== 28) {
+ return { project, skipped: true, reason: "not v28" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 29,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord {
+ const nextProject = { ...project };
+ if (Array.isArray(project.scenes)) {
+ nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
+ }
+ return nextProject;
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const nextScene = { ...scene };
+ if (isRecord(scene.tracks)) {
+ nextScene.tracks = migrateTracks({ tracks: scene.tracks });
+ }
+ return nextScene;
+}
+
+function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
+ const nextTracks = { ...tracks };
+ if (isRecord(tracks.main)) {
+ nextTracks.main = migrateTrack({ track: tracks.main });
+ }
+ if (Array.isArray(tracks.overlay)) {
+ nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
+ }
+ if (Array.isArray(tracks.audio)) {
+ nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
+ }
+ return nextTracks;
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track) || !Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element)) {
+ return element;
+ }
+
+ const nextElement = { ...element };
+ const params: ProjectRecord = isRecord(element.params)
+ ? { ...element.params }
+ : {};
+
+ copyTransformParams({ source: element, params });
+ copyPrimitiveParam({ source: element, params, sourceKey: "opacity", paramKey: "opacity" });
+ copyPrimitiveParam({
+ source: element,
+ params,
+ sourceKey: "blendMode",
+ paramKey: "blendMode",
+ });
+
+ if (element.type === "audio" || element.type === "video") {
+ copyPrimitiveParam({
+ source: element,
+ params,
+ sourceKey: "volume",
+ paramKey: "volume",
+ });
+ copyPrimitiveParam({
+ source: element,
+ params,
+ sourceKey: "muted",
+ paramKey: "muted",
+ });
+ }
+
+ if (element.type === "text") {
+ copyTextParams({ source: element, params });
+ }
+
+ nextElement.params = params;
+ return nextElement;
+}
+
+function copyTransformParams({
+ source,
+ params,
+}: {
+ source: ProjectRecord;
+ params: ProjectRecord;
+}): void {
+ if (!isRecord(source.transform)) {
+ return;
+ }
+ if (isRecord(source.transform.position)) {
+ copyPrimitiveParam({
+ source: source.transform.position,
+ params,
+ sourceKey: "x",
+ paramKey: "transform.positionX",
+ });
+ copyPrimitiveParam({
+ source: source.transform.position,
+ params,
+ sourceKey: "y",
+ paramKey: "transform.positionY",
+ });
+ }
+ copyPrimitiveParam({
+ source: source.transform,
+ params,
+ sourceKey: "scaleX",
+ paramKey: "transform.scaleX",
+ });
+ copyPrimitiveParam({
+ source: source.transform,
+ params,
+ sourceKey: "scaleY",
+ paramKey: "transform.scaleY",
+ });
+ copyPrimitiveParam({
+ source: source.transform,
+ params,
+ sourceKey: "rotate",
+ paramKey: "transform.rotate",
+ });
+}
+
+function copyTextParams({
+ source,
+ params,
+}: {
+ source: ProjectRecord;
+ params: ProjectRecord;
+}): void {
+ for (const key of [
+ "content",
+ "fontSize",
+ "fontFamily",
+ "color",
+ "textAlign",
+ "fontWeight",
+ "fontStyle",
+ "textDecoration",
+ "letterSpacing",
+ "lineHeight",
+ ]) {
+ copyPrimitiveParam({ source, params, sourceKey: key, paramKey: key });
+ }
+
+ if (!isRecord(source.background)) {
+ return;
+ }
+ for (const key of [
+ "enabled",
+ "color",
+ "cornerRadius",
+ "paddingX",
+ "paddingY",
+ "offsetX",
+ "offsetY",
+ ]) {
+ copyPrimitiveParam({
+ source: source.background,
+ params,
+ sourceKey: key,
+ paramKey: `background.${key}`,
+ });
+ }
+}
+
+function copyPrimitiveParam({
+ source,
+ params,
+ sourceKey,
+ paramKey,
+}: {
+ source: ProjectRecord;
+ params: ProjectRecord;
+ sourceKey: string;
+ paramKey: string;
+}): void {
+ const value = source[sourceKey];
+ if (
+ typeof value === "number" ||
+ typeof value === "string" ||
+ typeof value === "boolean"
+ ) {
+ params[paramKey] = value;
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts
new file mode 100644
index 00000000..f3050742
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts
@@ -0,0 +1,138 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV29ToV30({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 30) {
+ return { project, skipped: true, reason: "already v30" };
+ }
+ if (version !== 29) {
+ return { project, skipped: true, reason: "not v29" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 30,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord {
+ const nextProject = { ...project };
+ if (Array.isArray(project.scenes)) {
+ nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
+ }
+ return nextProject;
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const nextScene = { ...scene };
+ if (isRecord(scene.tracks)) {
+ nextScene.tracks = migrateTracks({ tracks: scene.tracks });
+ }
+ return nextScene;
+}
+
+function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
+ const nextTracks = { ...tracks };
+ if (isRecord(tracks.main)) {
+ nextTracks.main = migrateTrack({ track: tracks.main });
+ }
+ if (Array.isArray(tracks.overlay)) {
+ nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
+ }
+ if (Array.isArray(tracks.audio)) {
+ nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
+ }
+ return nextTracks;
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track) || !Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element) || !isRecord(element.animations)) {
+ return element;
+ }
+
+ return {
+ ...element,
+ animations: migrateAnimations({ animations: element.animations }),
+ };
+}
+
+function migrateAnimations({
+ animations,
+}: {
+ animations: ProjectRecord;
+}): ProjectRecord {
+ if (!isRecord(animations.bindings) || !isRecord(animations.channels)) {
+ return animations;
+ }
+
+ const channels = animations.channels;
+ const nextAnimations: ProjectRecord = { ...animations };
+ for (const binding of Object.values(animations.bindings)) {
+ if (!isRecord(binding) || typeof binding.path !== "string") {
+ continue;
+ }
+ if (!Array.isArray(binding.components)) {
+ continue;
+ }
+
+ const componentEntries = binding.components.flatMap((component) => {
+ if (
+ !isRecord(component) ||
+ typeof component.key !== "string" ||
+ typeof component.channelId !== "string"
+ ) {
+ return [];
+ }
+ const channel = channels[component.channelId];
+ if (!isRecord(channel)) {
+ return [];
+ }
+ return [[component.key, migrateChannel({ channel })] as const];
+ });
+ if (componentEntries.length === 0) {
+ continue;
+ }
+
+ nextAnimations[binding.path] =
+ componentEntries.length === 1 && componentEntries[0]?.[0] === "value"
+ ? componentEntries[0][1]
+ : Object.fromEntries(componentEntries);
+ }
+
+ return nextAnimations;
+}
+
+function migrateChannel({ channel }: { channel: ProjectRecord }): ProjectRecord {
+ const { kind: _kind, ...nextChannel } = channel;
+ return nextChannel;
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts
index 84d97fd8..7a28134b 100644
--- a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts
+++ b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts
@@ -1,10 +1,10 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
-const LEGACY_FONT_WEIGHT_MAP = {
- normal: "400",
- bold: "700",
-} as const;
+const LEGACY_FONT_WEIGHT_MAP = new Map<string, string>([
+ ["normal", "400"],
+ ["bold", "700"],
+]);
const VALID_NUMERIC_FONT_WEIGHTS = new Set([
"100",
@@ -168,10 +168,9 @@ function normalizeFontWeight({ value }: { value: unknown }): unknown {
}
const normalized = value.trim().toLowerCase();
- if (normalized in LEGACY_FONT_WEIGHT_MAP) {
- return LEGACY_FONT_WEIGHT_MAP[
- normalized as keyof typeof LEGACY_FONT_WEIGHT_MAP
- ];
+ const mapped = LEGACY_FONT_WEIGHT_MAP.get(normalized);
+ if (mapped !== undefined) {
+ return mapped;
}
if (VALID_NUMERIC_FONT_WEIGHTS.has(normalized)) {
diff --git a/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts b/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts
new file mode 100644
index 00000000..512fc921
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts
@@ -0,0 +1,100 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV30ToV31({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 31) {
+ return { project, skipped: true, reason: "already v31" };
+ }
+ if (version !== 30) {
+ return { project, skipped: true, reason: "not v30" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 31,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord {
+ const nextProject = { ...project };
+ if (Array.isArray(project.scenes)) {
+ nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
+ }
+ return nextProject;
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const nextScene = { ...scene };
+ if (isRecord(scene.tracks)) {
+ nextScene.tracks = migrateTracks({ tracks: scene.tracks });
+ }
+ return nextScene;
+}
+
+function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
+ const nextTracks = { ...tracks };
+ if (isRecord(tracks.main)) {
+ nextTracks.main = migrateTrack({ track: tracks.main });
+ }
+ if (Array.isArray(tracks.overlay)) {
+ nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
+ }
+ if (Array.isArray(tracks.audio)) {
+ nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
+ }
+ return nextTracks;
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track) || !Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element) || !Array.isArray(element.masks)) {
+ return element;
+ }
+
+ return {
+ ...element,
+ masks: element.masks.map((mask) => migrateMask({ mask })),
+ };
+}
+
+function migrateMask({ mask }: { mask: unknown }): unknown {
+ if (!isRecord(mask) || mask.type !== "custom") {
+ return mask;
+ }
+
+ // "custom" is the only freeform-path mask type; a down-migration can map
+ // type === "freeform" back to "custom" without any additional marker.
+ return {
+ ...mask,
+ type: "freeform",
+ };
+}
diff --git a/apps/web/src/services/storage/migrations/v27-to-v28.ts b/apps/web/src/services/storage/migrations/v27-to-v28.ts
new file mode 100644
index 00000000..aaec6256
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v27-to-v28.ts
@@ -0,0 +1,14 @@
+import { StorageMigration, type StorageMigrationRunArgs } from "./base";
+import type { MigrationResult, ProjectRecord } from "./transformers/types";
+import { transformProjectV27ToV28 } from "./transformers/v27-to-v28";
+
+export class V27toV28Migration extends StorageMigration {
+ from = 27;
+ to = 28;
+
+ async run({
+ project,
+ }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
+ return transformProjectV27ToV28({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v28-to-v29.ts b/apps/web/src/services/storage/migrations/v28-to-v29.ts
new file mode 100644
index 00000000..a2e75ec0
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v28-to-v29.ts
@@ -0,0 +1,14 @@
+import { StorageMigration, type StorageMigrationRunArgs } from "./base";
+import type { MigrationResult, ProjectRecord } from "./transformers/types";
+import { transformProjectV28ToV29 } from "./transformers/v28-to-v29";
+
+export class V28toV29Migration extends StorageMigration {
+ from = 28;
+ to = 29;
+
+ async run({
+ project,
+ }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
+ return transformProjectV28ToV29({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v29-to-v30.ts b/apps/web/src/services/storage/migrations/v29-to-v30.ts
new file mode 100644
index 00000000..0b9c23b9
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v29-to-v30.ts
@@ -0,0 +1,14 @@
+import { StorageMigration, type StorageMigrationRunArgs } from "./base";
+import type { MigrationResult, ProjectRecord } from "./transformers/types";
+import { transformProjectV29ToV30 } from "./transformers/v29-to-v30";
+
+export class V29toV30Migration extends StorageMigration {
+ from = 29;
+ to = 30;
+
+ async run({
+ project,
+ }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
+ return transformProjectV29ToV30({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v30-to-v31.ts b/apps/web/src/services/storage/migrations/v30-to-v31.ts
new file mode 100644
index 00000000..35bf0969
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v30-to-v31.ts
@@ -0,0 +1,14 @@
+import { StorageMigration, type StorageMigrationRunArgs } from "./base";
+import type { MigrationResult, ProjectRecord } from "./transformers/types";
+import { transformProjectV30ToV31 } from "./transformers/v30-to-v31";
+
+export class V30toV31Migration extends StorageMigration {
+ from = 30;
+ to = 31;
+
+ async run({
+ project,
+ }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
+ return transformProjectV30ToV31({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/opfs-adapter.ts b/apps/web/src/services/storage/opfs-adapter.ts
index 879223b5..220ee452 100644
--- a/apps/web/src/services/storage/opfs-adapter.ts
+++ b/apps/web/src/services/storage/opfs-adapter.ts
@@ -27,7 +27,13 @@ export class OPFSAdapter implements StorageAdapter<File> {
}
}
- async set(key: string, file: File): Promise<void> {
+ async set({
+ key,
+ value: file,
+ }: {
+ key: string;
+ value: File;
+ }): Promise<void> {
const directory = await this.getDirectory();
const fileHandle = await directory.getFileHandle(key, { create: true });
const writable = await fileHandle.createWritable();
diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts
index 858198eb..e8fc26c0 100644
--- a/apps/web/src/services/storage/service.ts
+++ b/apps/web/src/services/storage/service.ts
@@ -22,12 +22,15 @@ import {
runStorageMigrations,
} from "@/services/storage/migrations";
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
+import { roundMediaTime } from "@/wasm";
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item): Bookmark | null => {
- if (typeof item === "number") return { time: item };
+ if (typeof item === "number") {
+ return { time: roundMediaTime({ time: item }) };
+ }
const obj = item as Record<string, unknown>;
if (
typeof obj !== "object" ||
@@ -37,10 +40,12 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
return null;
}
return {
- time: obj.time,
+ time: roundMediaTime({ time: obj.time }),
...(typeof obj.note === "string" && { note: obj.note }),
...(typeof obj.color === "string" && { color: obj.color }),
- ...(typeof obj.duration === "number" && { duration: obj.duration }),
+ ...(typeof obj.duration === "number" && {
+ duration: roundMediaTime({ time: obj.duration }),
+ }),
};
})
.filter((b): b is Bookmark => b !== null);
@@ -60,17 +65,17 @@ class StorageService {
version: 1,
};
- this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
- this.config.projectsDb,
- "projects",
- this.config.version,
- );
+ this.projectsAdapter = new IndexedDBAdapter<SerializedProject>({
+ dbName: this.config.projectsDb,
+ storeName: "projects",
+ version: this.config.version,
+ });
- this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>(
- this.config.savedSoundsDb,
- "saved-sounds",
- this.config.version,
- );
+ this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>({
+ dbName: this.config.savedSoundsDb,
+ storeName: "saved-sounds",
+ version: this.config.version,
+ });
}
private async ensureMigrations(): Promise<void> {
@@ -86,11 +91,11 @@ class StorageService {
}
private getProjectMediaAdapters({ projectId }: { projectId: string }) {
- const mediaMetadataAdapter = new IndexedDBAdapter<MediaAssetData>(
- `${this.config.mediaDb}-${projectId}`,
- "media-metadata",
- this.config.version,
- );
+ const mediaMetadataAdapter = new IndexedDBAdapter<MediaAssetData>({
+ dbName: `${this.config.mediaDb}-${projectId}`,
+ storeName: "media-metadata",
+ version: this.config.version,
+ });
const mediaAssetsAdapter = new OPFSAdapter(`media-files-${projectId}`);
@@ -156,7 +161,10 @@ class StorageService {
timelineViewState: project.timelineViewState,
};
- await this.projectsAdapter.set(project.metadata.id, serializedProject);
+ await this.projectsAdapter.set({
+ key: project.metadata.id,
+ value: serializedProject,
+ });
}
async loadProject({
@@ -198,9 +206,11 @@ class StorageService {
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
- duration:
- serializedProject.metadata.duration ??
- getProjectDurationFromScenes({ scenes }),
+ duration: roundMediaTime({
+ time:
+ serializedProject.metadata.duration ??
+ getProjectDurationFromScenes({ scenes }),
+ }),
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
},
@@ -253,11 +263,13 @@ class StorageService {
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
- duration:
- serializedProject.metadata.duration ??
- getProjectDurationFromScenes({
- scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
- }),
+ duration: roundMediaTime({
+ time:
+ serializedProject.metadata.duration ??
+ getProjectDurationFromScenes({
+ scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
+ }),
+ }),
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
});
@@ -296,8 +308,14 @@ class StorageService {
};
try {
- await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file);
- await mediaMetadataAdapter.set(mediaAsset.id, metadata);
+ await mediaAssetsAdapter.set({
+ key: mediaAsset.id,
+ value: mediaAsset.file,
+ });
+ await mediaMetadataAdapter.set({
+ key: mediaAsset.id,
+ value: metadata,
+ });
} catch (error) {
try {
await mediaAssetsAdapter.remove(mediaAsset.id);
@@ -492,7 +510,10 @@ class StorageService {
lastModified: new Date().toISOString(),
};
- await this.savedSoundsAdapter.set("user-sounds", updatedData);
+ await this.savedSoundsAdapter.set({
+ key: "user-sounds",
+ value: updatedData,
+ });
} catch (error) {
console.error("Failed to save sound effect:", error);
throw error;
@@ -508,7 +529,10 @@ class StorageService {
lastModified: new Date().toISOString(),
};
- await this.savedSoundsAdapter.set("user-sounds", updatedData);
+ await this.savedSoundsAdapter.set({
+ key: "user-sounds",
+ value: updatedData,
+ });
} catch (error) {
console.error("Failed to remove saved sound:", error);
throw error;
diff --git a/apps/web/src/services/storage/types.ts b/apps/web/src/services/storage/types.ts
index 58c14b49..f20b311a 100644
--- a/apps/web/src/services/storage/types.ts
+++ b/apps/web/src/services/storage/types.ts
@@ -8,7 +8,7 @@ import type { TScene } from "@/timeline";
export interface StorageAdapter<T> {
get(key: string): Promise<T | null>;
- set(key: string, value: T): Promise<void>;
+ set(args: { key: string; value: T }): Promise<void>;
remove(key: string): Promise<void>;
list(): Promise<string[]>;
clear(): Promise<void>;
diff --git a/apps/web/src/sounds/sounds-store.ts b/apps/web/src/sounds/sounds-store.ts
index b9b3f070..13ced962 100644
--- a/apps/web/src/sounds/sounds-store.ts
+++ b/apps/web/src/sounds/sounds-store.ts
@@ -4,6 +4,7 @@ import { storageService } from "@/services/storage/service";
import { toast } from "sonner";
import { EditorCore } from "@/core";
import { buildLibraryAudioElement } from "@/timeline/element-utils";
+import { mediaTimeFromSeconds } from "@/wasm";
interface SoundsStore {
topSoundEffects: SoundEffect[];
@@ -227,7 +228,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
const element = buildLibraryAudioElement({
sourceUrl: audioUrl,
name: sound.name,
- duration: sound.duration,
+ duration: mediaTimeFromSeconds({ seconds: sound.duration }),
startTime: currentTime,
buffer,
});
diff --git a/apps/web/src/stickers/providers/index.ts b/apps/web/src/stickers/providers/index.ts
index ca23963c..56b3f6cb 100644
--- a/apps/web/src/stickers/providers/index.ts
+++ b/apps/web/src/stickers/providers/index.ts
@@ -1,24 +1,24 @@
-import { stickersRegistry } from "../registry";
-import type { StickerProvider } from "@/stickers/types";
-import { flagsProvider } from "./flags";
-import { logosProvider } from "./logos";
-import { shapesProvider } from "./shapes";
-
-const defaultProviders: StickerProvider[] = [
- logosProvider,
- flagsProvider,
- shapesProvider,
-];
-
-export function registerDefaultStickerProviders({
- providersToRegister = defaultProviders,
-}: {
- providersToRegister?: StickerProvider[];
-} = {}): void {
- for (const provider of providersToRegister) {
- if (stickersRegistry.has(provider.id)) {
- continue;
- }
- stickersRegistry.register(provider.id, provider);
- }
-}
+import { stickersRegistry } from "../registry";
+import type { StickerProvider } from "@/stickers/types";
+import { flagsProvider } from "./flags";
+import { logosProvider } from "./logos";
+import { shapesProvider } from "./shapes";
+
+const defaultProviders: StickerProvider[] = [
+ logosProvider,
+ flagsProvider,
+ shapesProvider,
+];
+
+export function registerDefaultStickerProviders({
+ providersToRegister = defaultProviders,
+}: {
+ providersToRegister?: StickerProvider[];
+} = {}): void {
+ for (const provider of providersToRegister) {
+ if (stickersRegistry.has(provider.id)) {
+ continue;
+ }
+ stickersRegistry.register(provider.id, provider);
+ }
+}
diff --git a/apps/web/src/subtitles/build-subtitle-text-element.ts b/apps/web/src/subtitles/build-subtitle-text-element.ts
index c4ac21ac..87cc6a03 100644
--- a/apps/web/src/subtitles/build-subtitle-text-element.ts
+++ b/apps/web/src/subtitles/build-subtitle-text-element.ts
@@ -5,8 +5,15 @@ import {
setCanvasLetterSpacing,
} from "@/text/layout";
import { DEFAULTS } from "@/timeline/defaults";
-import { TICKS_PER_SECOND } from "@/wasm";
+import { mediaTimeFromSeconds } from "@/wasm";
import type { CreateTextElement } from "@/timeline";
+import type { TextBackground } from "@/text/background";
+import type {
+ TextAlign,
+ TextDecoration,
+ TextFontStyle,
+ TextFontWeight,
+} from "@/text/primitives";
import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
const SUBTITLE_MAX_WIDTH_RATIO = 0.8;
@@ -89,8 +96,8 @@ function measureWrappedTextBlock({
ctx: CanvasRenderingContext2D;
content: string;
canvasHeight: number;
- textAlign: CreateTextElement["textAlign"];
- background: CreateTextElement["background"];
+ textAlign: TextAlign;
+ background: TextBackground;
fontSize: number;
lineHeight: number;
}) {
@@ -107,7 +114,7 @@ function measureWrappedTextBlock({
textAlign,
block,
background,
- fontSizeRatio: fontSize / DEFAULTS.text.element.fontSize,
+ fontSizeRatio: fontSize / 15,
});
return {
@@ -124,13 +131,13 @@ function resolveSubtitleStyle({
fontFamily: string;
fontSize: number;
color: string;
- textAlign: CreateTextElement["textAlign"];
- fontWeight: CreateTextElement["fontWeight"];
- fontStyle: CreateTextElement["fontStyle"];
- textDecoration: CreateTextElement["textDecoration"];
+ textAlign: TextAlign;
+ fontWeight: TextFontWeight;
+ fontStyle: TextFontStyle;
+ textDecoration: TextDecoration;
letterSpacing: number;
lineHeight: number;
- background: CreateTextElement["background"];
+ background: TextBackground;
placement: NonNullable<SubtitleStyleOverrides["placement"]>;
} {
const fontSize =
@@ -139,18 +146,17 @@ function resolveSubtitleStyle({
: (style?.fontSize ?? SUBTITLE_FONT_SIZE);
return {
- fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily,
+ fontFamily: style?.fontFamily ?? "Arial",
fontSize,
- color: style?.color ?? DEFAULTS.text.element.color,
+ color: style?.color ?? "#ffffff",
textAlign: style?.textAlign ?? "center",
fontWeight: style?.fontWeight ?? "bold",
- fontStyle: style?.fontStyle ?? DEFAULTS.text.element.fontStyle,
- textDecoration:
- style?.textDecoration ?? DEFAULTS.text.element.textDecoration,
+ fontStyle: style?.fontStyle ?? "normal",
+ textDecoration: style?.textDecoration ?? "none",
letterSpacing: style?.letterSpacing ?? DEFAULTS.text.letterSpacing,
lineHeight: style?.lineHeight ?? DEFAULTS.text.lineHeight,
background: {
- ...DEFAULTS.text.element.background,
+ ...DEFAULTS.text.background,
enabled: false,
...(style?.background ?? {}),
},
@@ -188,7 +194,7 @@ function resolvePositionX({
visualRect,
}: {
canvasWidth: number;
- textAlign: CreateTextElement["textAlign"];
+ textAlign: TextAlign;
placement: ReturnType<typeof resolveSubtitleStyle>["placement"];
visualRect: { left: number; width: number };
}): number {
@@ -309,25 +315,34 @@ export function buildSubtitleTextElement({
return {
...DEFAULTS.text.element,
name: `Caption ${index + 1}`,
- content,
- duration: Math.round(caption.duration * TICKS_PER_SECOND),
- startTime: Math.round(caption.startTime * TICKS_PER_SECOND),
- fontSize: style.fontSize,
- fontFamily: style.fontFamily,
- color: style.color,
- textAlign: style.textAlign,
- fontWeight: style.fontWeight,
- fontStyle: style.fontStyle,
- textDecoration: style.textDecoration,
- letterSpacing: style.letterSpacing,
- lineHeight: style.lineHeight,
- background: style.background,
- transform: {
- ...DEFAULTS.text.element.transform,
- position: {
- x: positionX,
- y: positionY,
- },
+ duration: mediaTimeFromSeconds({ seconds: caption.duration }),
+ startTime: mediaTimeFromSeconds({ seconds: caption.startTime }),
+ params: {
+ ...DEFAULTS.text.element.params,
+ content,
+ fontSize: style.fontSize,
+ fontFamily: style.fontFamily,
+ color: style.color,
+ textAlign: style.textAlign,
+ fontWeight: style.fontWeight,
+ fontStyle: style.fontStyle,
+ textDecoration: style.textDecoration,
+ letterSpacing: style.letterSpacing,
+ lineHeight: style.lineHeight,
+ "background.enabled": style.background.enabled,
+ "background.color": style.background.color,
+ "background.cornerRadius":
+ style.background.cornerRadius ?? DEFAULTS.text.background.cornerRadius,
+ "background.paddingX":
+ style.background.paddingX ?? DEFAULTS.text.background.paddingX,
+ "background.paddingY":
+ style.background.paddingY ?? DEFAULTS.text.background.paddingY,
+ "background.offsetX":
+ style.background.offsetX ?? DEFAULTS.text.background.offsetX,
+ "background.offsetY":
+ style.background.offsetY ?? DEFAULTS.text.background.offsetY,
+ "transform.positionX": positionX,
+ "transform.positionY": positionY,
},
};
}
diff --git a/apps/web/src/subtitles/components/assets-view.tsx b/apps/web/src/subtitles/components/assets-view.tsx
index 7e2ddad9..84480fa4 100644
--- a/apps/web/src/subtitles/components/assets-view.tsx
+++ b/apps/web/src/subtitles/components/assets-view.tsx
@@ -64,6 +64,7 @@ const IDLE_STATE: ProcessingState = {
warnings: [],
};
+/* eslint-disable opencut/prefer-object-params -- React reducers must accept (state, action). */
function processingReducer(
state: ProcessingState,
action: ProcessingAction,
@@ -80,6 +81,7 @@ function processingReducer(
return { status: "idle", error: action.error, warnings: [] };
}
}
+/* eslint-enable opencut/prefer-object-params */
export function Captions() {
const [selectedLanguage, setSelectedLanguage] =
diff --git a/apps/web/src/subtitles/insert.ts b/apps/web/src/subtitles/insert.ts
index 50d6c40b..2a71e181 100644
--- a/apps/web/src/subtitles/insert.ts
+++ b/apps/web/src/subtitles/insert.ts
@@ -18,7 +18,7 @@ export function insertCaptionChunksAsTextTrack({
return null;
}
- const addTrackCommand = new AddTrackCommand("text", 0);
+ const addTrackCommand = new AddTrackCommand({ type: "text", index: 0 });
const trackId = addTrackCommand.getTrackId();
const canvasSize = editor.project.getActive().settings.canvasSize;
const insertCommands = captions.map(
diff --git a/apps/web/src/subtitles/types.ts b/apps/web/src/subtitles/types.ts
index e4f5db3c..ec485f22 100644
--- a/apps/web/src/subtitles/types.ts
+++ b/apps/web/src/subtitles/types.ts
@@ -1,4 +1,10 @@
-import type { TextBackground, TextElement } from "@/timeline";
+import type { TextBackground } from "@/text/background";
+import type {
+ TextAlign,
+ TextDecoration,
+ TextFontStyle,
+ TextFontWeight,
+} from "@/text/primitives";
import type { CaptionChunk } from "@/transcription/types";
export interface SubtitlePlacementStyle {
@@ -26,10 +32,10 @@ export interface SubtitleStyleOverrides {
color?: string;
background?: Pick<TextBackground, "enabled" | "color"> &
Partial<Omit<TextBackground, "enabled" | "color">>;
- textAlign?: TextElement["textAlign"];
- fontWeight?: TextElement["fontWeight"];
- fontStyle?: TextElement["fontStyle"];
- textDecoration?: TextElement["textDecoration"];
+ textAlign?: TextAlign;
+ fontWeight?: TextFontWeight;
+ fontStyle?: TextFontStyle;
+ textDecoration?: TextDecoration;
letterSpacing?: number;
lineHeight?: number;
placement?: SubtitlePlacementStyle;
diff --git a/apps/web/src/text/background.ts b/apps/web/src/text/background.ts
index be5ab6f5..baa67a52 100644
--- a/apps/web/src/text/background.ts
+++ b/apps/web/src/text/background.ts
@@ -1,2 +1,12 @@
export const CORNER_RADIUS_MIN = 0;
export const CORNER_RADIUS_MAX = 100;
+
+export interface TextBackground {
+ enabled: boolean;
+ color: string;
+ cornerRadius?: number;
+ paddingX?: number;
+ paddingY?: number;
+ offsetX?: number;
+ offsetY?: number;
+}
diff --git a/apps/web/src/text/components/assets-view.tsx b/apps/web/src/text/components/assets-view.tsx
index 25a49893..036bc898 100644
--- a/apps/web/src/text/components/assets-view.tsx
+++ b/apps/web/src/text/components/assets-view.tsx
@@ -1,46 +1,47 @@
-import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import { useEditor } from "@/editor/use-editor";
-import { DEFAULTS } from "@/timeline/defaults";
-import { buildTextElement } from "@/timeline/element-utils";
-
-export function TextView() {
- const editor = useEditor();
-
- const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
- const activeScene = editor.scenes.getActiveScene();
- if (!activeScene) return;
-
- const element = buildTextElement({
- raw: DEFAULTS.text.element,
- startTime: currentTime,
- });
-
- editor.timeline.insertElement({
- element,
- placement: { mode: "auto" },
- });
- };
-
- return (
- <PanelView title="Text">
- <DraggableItem
- name="Default text"
- preview={
- <div className="bg-accent flex size-full items-center justify-center rounded">
- <span className="text-xs select-none">Default text</span>
- </div>
- }
- dragData={{
- id: "temp-text-id",
- type: DEFAULTS.text.element.type,
- name: DEFAULTS.text.element.name,
- content: DEFAULTS.text.element.content,
- }}
- aspectRatio={1}
- onAddToTimeline={handleAddToTimeline}
- shouldShowLabel={false}
- />
- </PanelView>
- );
-}
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import { useEditor } from "@/editor/use-editor";
+import { DEFAULTS } from "@/timeline/defaults";
+import { buildTextElement } from "@/timeline/element-utils";
+import type { MediaTime } from "@/wasm";
+
+export function TextView() {
+ const editor = useEditor();
+
+ const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => {
+ const activeScene = editor.scenes.getActiveScene();
+ if (!activeScene) return;
+
+ const element = buildTextElement({
+ raw: DEFAULTS.text.element,
+ startTime: currentTime,
+ });
+
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ };
+
+ return (
+ <PanelView title="Text">
+ <DraggableItem
+ name="Default text"
+ preview={
+ <div className="bg-accent flex size-full items-center justify-center rounded">
+ <span className="text-xs select-none">Default text</span>
+ </div>
+ }
+ dragData={{
+ id: "temp-text-id",
+ type: DEFAULTS.text.element.type,
+ name: DEFAULTS.text.element.name,
+ content: "Default text",
+ }}
+ aspectRatio={1}
+ onAddToTimeline={handleAddToTimeline}
+ shouldShowLabel={false}
+ />
+ </PanelView>
+ );
+}
diff --git a/apps/web/src/text/components/text-tab.tsx b/apps/web/src/text/components/text-tab.tsx
deleted file mode 100644
index 0f0b274a..00000000
--- a/apps/web/src/text/components/text-tab.tsx
+++ /dev/null
@@ -1,767 +0,0 @@
-"use client";
-
-import { Textarea } from "@/components/ui/textarea";
-import { FontPicker } from "@/components/ui/font-picker";
-import type { TextElement } from "@/timeline";
-import { NumberField } from "@/components/ui/number-field";
-import { useRef } from "react";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { ColorPicker } from "@/components/ui/color-picker";
-import { Button } from "@/components/ui/button";
-import { uppercase } from "@/utils/string";
-import { clamp, formatNumberForDisplay } from "@/utils/math";
-import { useEditor } from "@/editor/use-editor";
-import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "@/text/background";
-import {
- DEFAULT_TEXT_COLOR,
- MAX_FONT_SIZE,
- MIN_FONT_SIZE,
-} from "@/text/typography";
-import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
-import { useKeyframedColorProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-color-property";
-import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
-import { isPropertyAtDefault } from "@/rendering/components/transform-tab";
-import {
- resolveColorAtTime,
- resolveNumberAtTime,
-} from "@/animation/values";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- MinusSignIcon,
- PlusSignIcon,
- TextFontIcon,
-} from "@hugeicons/core-free-icons";
-import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons";
-import { DEFAULTS } from "@/timeline/defaults";
-import { cn } from "@/utils/ui";
-
-export function TextTab({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- return (
- <div className="flex flex-col">
- <ContentSection element={element} trackId={trackId} />
- <TypographySection element={element} trackId={trackId} />
- <SpacingSection element={element} trackId={trackId} />
- <BackgroundSection element={element} trackId={trackId} />
- </div>
- );
-}
-
-function ContentSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
-
- const content = usePropertyDraft({
- displayValue: element.content,
- parse: (input) => input,
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { content: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:content`}>
- <SectionHeader>
- <SectionTitle>Content</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <Textarea
- placeholder="Name"
- value={content.displayValue}
- className="min-h-20"
- onFocus={content.onFocus}
- onChange={content.onChange}
- onBlur={content.onBlur}
- />
- </SectionContent>
- </Section>
- );
-}
-
-function TypographySection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedTextColor = resolveColorAtTime({
- baseColor: element.color,
- animations: element.animations,
- propertyPath: "color",
- localTime,
- });
-
- const textColor = useKeyframedColorProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "color",
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor: resolvedTextColor,
- buildBaseUpdates: ({ value }) => ({ color: value }),
- });
-
- const fontSize = usePropertyDraft({
- displayValue: element.fontSize.toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({
- value: Math.round(parsed),
- min: MIN_FONT_SIZE,
- max: MAX_FONT_SIZE,
- });
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { fontSize: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:typography`}>
- <SectionHeader>
- <SectionTitle>Typography</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField label="Font">
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value) =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { fontFamily: value },
- },
- ],
- })
- }
- />
- </SectionField>
- <SectionField label="Size">
- <NumberField
- value={fontSize.displayValue}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onFocus={fontSize.onFocus}
- onChange={fontSize.onChange}
- onBlur={fontSize.onBlur}
- onScrub={fontSize.scrubTo}
- onScrubEnd={fontSize.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- fontSize: DEFAULTS.text.element.fontSize,
- },
- },
- ],
- })
- }
- isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
- icon={<HugeiconsIcon icon={TextFontIcon} />}
- />
- </SectionField>
- <SectionField
- label="Color"
- beforeLabel={
- <KeyframeToggle
- isActive={textColor.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle text color keyframe"
- onToggle={textColor.toggleKeyframe}
- />
- }
- >
- <ColorPicker
- value={uppercase({
- string: resolvedTextColor.replace("#", ""),
- })}
- onChange={(color) => textColor.onChange({ color: `#${color}` })}
- onChangeEnd={textColor.onChangeEnd}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function SpacingSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
-
- const letterSpacing = usePropertyDraft({
- displayValue: Math.round(
- element.letterSpacing ?? DEFAULTS.text.letterSpacing,
- ).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { letterSpacing: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- const lineHeight = usePropertyDraft({
- displayValue: formatNumberForDisplay({
- value: element.lineHeight ?? DEFAULTS.text.lineHeight,
- fractionDigits: 1,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed)
- ? null
- : Math.max(0.1, Math.round(parsed * 10) / 10);
- },
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { lineHeight: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
- });
-
- return (
- <Section collapsible sectionKey={`${element.id}:spacing`}>
- <SectionHeader>
- <SectionTitle>Spacing</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex items-start gap-2">
- <SectionField label="Letter spacing" className="w-1/2">
- <NumberField
- value={letterSpacing.displayValue}
- onFocus={letterSpacing.onFocus}
- onChange={letterSpacing.onChange}
- onBlur={letterSpacing.onBlur}
- onScrub={letterSpacing.scrubTo}
- onScrubEnd={letterSpacing.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { letterSpacing: DEFAULTS.text.letterSpacing },
- },
- ],
- })
- }
- isDefault={
- (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
- DEFAULTS.text.letterSpacing
- }
- icon={<OcTextWidthIcon size={14} />}
- />
- </SectionField>
- <SectionField label="Line height" className="w-1/2">
- <NumberField
- value={lineHeight.displayValue}
- onFocus={lineHeight.onFocus}
- onChange={lineHeight.onChange}
- onBlur={lineHeight.onBlur}
- onScrub={lineHeight.scrubTo}
- onScrubEnd={lineHeight.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { lineHeight: DEFAULTS.text.lineHeight },
- },
- ],
- })
- }
- isDefault={
- (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
- DEFAULTS.text.lineHeight
- }
- icon={<OcTextHeightIcon size={14} />}
- />
- </SectionField>
- </div>
- </SectionContent>
- </Section>
- );
-}
-
-function BackgroundSection({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedBgColor = resolveColorAtTime({
- baseColor: element.background.color,
- animations: element.animations,
- propertyPath: "background.color",
- localTime,
- });
-
- const bgColor = useKeyframedColorProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.color",
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor: resolvedBgColor,
- buildBaseUpdates: ({ value }) => ({
- background: { ...element.background, color: value },
- }),
- });
-
- const bg = element.background;
-
- const resolvedPaddingX = resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- });
- const resolvedPaddingY = resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- });
- const resolvedOffsetX = resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- });
- const resolvedOffsetY = resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- });
- const resolvedCornerRadius = resolveNumberAtTime({
- baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- });
-
- const paddingX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedPaddingX).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
- },
- valueAtPlayhead: resolvedPaddingX,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, paddingX: value },
- }),
- });
-
- const paddingY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedPaddingY).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
- },
- valueAtPlayhead: resolvedPaddingY,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, paddingY: value },
- }),
- });
-
- const offsetX = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOffsetX).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- valueAtPlayhead: resolvedOffsetX,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, offsetX: value },
- }),
- });
-
- const offsetY = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedOffsetY).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
- },
- valueAtPlayhead: resolvedOffsetY,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, offsetY: value },
- }),
- });
-
- const cornerRadius = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: Math.round(resolvedCornerRadius).toString(),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({
- value: Math.round(parsed),
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- });
- },
- valueAtPlayhead: resolvedCornerRadius,
- step: 1,
- buildBaseUpdates: ({ value }) => ({
- background: { ...bg, cornerRadius: value },
- }),
- });
-
- const toggleBackgroundEnabled = () => {
- const enabled = !element.background.enabled;
- const color =
- enabled && element.background.color === "transparent"
- ? lastSelectedColor.current
- : element.background.color;
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- background: {
- ...element.background,
- enabled,
- color,
- },
- },
- },
- ],
- });
- };
-
- return (
- <Section
- collapsible
- defaultOpen={element.background.enabled}
- sectionKey={`${element.id}:background`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleBackgroundEnabled();
- }}
- >
- <HugeiconsIcon
- icon={element.background.enabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Background</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(
- !element.background.enabled && "pointer-events-none opacity-50",
- )}
- >
- <SectionFields>
- <SectionField
- label="Color"
- beforeLabel={
- <KeyframeToggle
- isActive={bgColor.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background color keyframe"
- onToggle={bgColor.toggleKeyframe}
- />
- }
- >
- <ColorPicker
- value={
- !element.background.enabled ||
- element.background.color === "transparent"
- ? lastSelectedColor.current.replace("#", "")
- : resolvedBgColor.replace("#", "")
- }
- onChange={(color) => {
- const hexColor = `#${color}`;
- if (color !== "transparent") {
- lastSelectedColor.current = hexColor;
- }
- bgColor.onChange({ color: hexColor });
- }}
- onChangeEnd={bgColor.onChangeEnd}
- />
- </SectionField>
- <div className="flex items-start gap-2">
- <SectionField
- label="Width"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={paddingX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background width keyframe"
- onToggle={paddingX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="W"
- value={paddingX.displayValue}
- min={0}
- onFocus={paddingX.onFocus}
- onChange={paddingX.onChange}
- onBlur={paddingX.onBlur}
- onScrub={paddingX.scrubTo}
- onScrubEnd={paddingX.commitScrub}
- onReset={() =>
- paddingX.commitValue({
- value: DEFAULTS.text.background.paddingX,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedPaddingX,
- staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- defaultValue: DEFAULTS.text.background.paddingX,
- })}
- />
- </SectionField>
- <SectionField
- label="Height"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={paddingY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle background height keyframe"
- onToggle={paddingY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="H"
- value={paddingY.displayValue}
- min={0}
- onFocus={paddingY.onFocus}
- onChange={paddingY.onChange}
- onBlur={paddingY.onBlur}
- onScrub={paddingY.scrubTo}
- onScrubEnd={paddingY.commitScrub}
- onReset={() =>
- paddingY.commitValue({
- value: DEFAULTS.text.background.paddingY,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedPaddingY,
- staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- defaultValue: DEFAULTS.text.background.paddingY,
- })}
- />
- </SectionField>
- </div>
- <div className="flex items-start gap-2">
- <SectionField
- label="X-offset"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={offsetX.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle x-offset keyframe"
- onToggle={offsetX.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="X"
- value={offsetX.displayValue}
- onFocus={offsetX.onFocus}
- onChange={offsetX.onChange}
- onBlur={offsetX.onBlur}
- onScrub={offsetX.scrubTo}
- onScrubEnd={offsetX.commitScrub}
- onReset={() =>
- offsetX.commitValue({
- value: DEFAULTS.text.background.offsetX,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOffsetX,
- staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- defaultValue: DEFAULTS.text.background.offsetX,
- })}
- />
- </SectionField>
- <SectionField
- label="Y-offset"
- className="w-1/2"
- beforeLabel={
- <KeyframeToggle
- isActive={offsetY.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle y-offset keyframe"
- onToggle={offsetY.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="Y"
- value={offsetY.displayValue}
- onFocus={offsetY.onFocus}
- onChange={offsetY.onChange}
- onBlur={offsetY.onBlur}
- onScrub={offsetY.scrubTo}
- onScrubEnd={offsetY.commitScrub}
- onReset={() =>
- offsetY.commitValue({
- value: DEFAULTS.text.background.offsetY,
- })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedOffsetY,
- staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- defaultValue: DEFAULTS.text.background.offsetY,
- })}
- />
- </SectionField>
- </div>
- <SectionField
- label="Corner radius"
- beforeLabel={
- <KeyframeToggle
- isActive={cornerRadius.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle corner radius keyframe"
- onToggle={cornerRadius.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon="R"
- value={cornerRadius.displayValue}
- min={CORNER_RADIUS_MIN}
- max={CORNER_RADIUS_MAX}
- onFocus={cornerRadius.onFocus}
- onChange={cornerRadius.onChange}
- onBlur={cornerRadius.onBlur}
- onScrub={cornerRadius.scrubTo}
- onScrubEnd={cornerRadius.commitScrub}
- onReset={() =>
- cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
- }
- isDefault={isPropertyAtDefault({
- hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedCornerRadius,
- staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- defaultValue: CORNER_RADIUS_MIN,
- })}
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
diff --git a/apps/web/src/text/layout.ts b/apps/web/src/text/layout.ts
index aabf5ca9..c52abc30 100644
--- a/apps/web/src/text/layout.ts
+++ b/apps/web/src/text/layout.ts
@@ -1,5 +1,6 @@
-import type { TextBackground, TextElement } from "@/timeline";
+import type { TextBackground } from "@/text/background";
import { DEFAULTS } from "@/timeline/defaults";
+import type { TextAlign } from "@/text/primitives";
type TextRect = {
left: number;
@@ -78,7 +79,7 @@ function getTextRect({
textAlign,
block,
}: {
- textAlign: TextElement["textAlign"];
+ textAlign: TextAlign;
block: TextBlockMeasurement;
}): TextRect {
const textAlignToLeft: Record<typeof textAlign, number> = {
@@ -114,7 +115,7 @@ export function getTextBackgroundRect({
background,
fontSizeRatio = 1,
}: {
- textAlign: TextElement["textAlign"];
+ textAlign: TextAlign;
block: TextBlockMeasurement;
background: TextBackground;
fontSizeRatio?: number;
@@ -145,7 +146,7 @@ export function getTextVisualRect({
background,
fontSizeRatio = 1,
}: {
- textAlign: TextElement["textAlign"];
+ textAlign: TextAlign;
block: TextBlockMeasurement;
background: TextBackground;
fontSizeRatio?: number;
diff --git a/apps/web/src/text/measure-element.ts b/apps/web/src/text/measure-element.ts
index ec14421b..28e97eac 100644
--- a/apps/web/src/text/measure-element.ts
+++ b/apps/web/src/text/measure-element.ts
@@ -1,6 +1,7 @@
import { CORNER_RADIUS_MIN } from "@/text/background";
import { DEFAULTS } from "@/timeline/defaults";
-import type { TextBackground, TextElement } from "@/timeline";
+import type { TextElement } from "@/timeline";
+import type { TextBackground } from "@/text/background";
import { resolveNumberAtTime } from "@/animation/values";
import {
getTextVisualRect,
@@ -8,6 +9,11 @@ import {
import {
measureTextLayout,
type MeasuredTextLayout,
+ type TextAlign,
+ type TextDecoration,
+ type TextFontStyle,
+ type TextFontWeight,
+ type TextLayoutParams,
} from "./primitives";
export interface ResolvedTextBackground extends TextBackground {
@@ -67,23 +73,14 @@ export function measureTextElement({
localTime: number;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
}): MeasuredTextElement {
+ const text = buildTextLayoutParamsFromElement({ element });
const measuredLayout = measureTextLayout({
- text: {
- content: element.content,
- fontSize: element.fontSize,
- fontFamily: element.fontFamily,
- fontWeight: element.fontWeight,
- fontStyle: element.fontStyle,
- textAlign: element.textAlign,
- textDecoration: element.textDecoration,
- letterSpacing: element.letterSpacing,
- lineHeight: element.lineHeight,
- },
+ text,
canvasHeight,
ctx,
});
- const bg = element.background;
+ const bg = buildTextBackgroundFromElement({ element });
const resolvedBackground: ResolvedTextBackground = {
...bg,
paddingX: resolveNumberAtTime({
@@ -119,7 +116,7 @@ export function measureTextElement({
};
const visualRect = getTextVisualRect({
- textAlign: element.textAlign,
+ textAlign: text.textAlign,
block: measuredLayout.block,
background: resolvedBackground,
fontSizeRatio: measuredLayout.fontSizeRatio,
@@ -131,3 +128,180 @@ export function measureTextElement({
visualRect,
};
}
+
+export function buildTextLayoutParamsFromElement({
+ element,
+}: {
+ element: TextElement;
+}): TextLayoutParams {
+ return {
+ content: readStringParam({
+ params: element.params,
+ key: "content",
+ fallback: "Default text",
+ }),
+ fontSize: readNumberParam({
+ params: element.params,
+ key: "fontSize",
+ fallback: 15,
+ }),
+ fontFamily: readStringParam({
+ params: element.params,
+ key: "fontFamily",
+ fallback: "Arial",
+ }),
+ fontWeight: readFontWeight({
+ value: element.params.fontWeight,
+ fallback: "normal",
+ }),
+ fontStyle: readFontStyle({
+ value: element.params.fontStyle,
+ fallback: "normal",
+ }),
+ textAlign: readTextAlign({
+ value: element.params.textAlign,
+ fallback: "center",
+ }),
+ textDecoration: readTextDecoration({
+ value: element.params.textDecoration,
+ fallback: "none",
+ }),
+ letterSpacing: readNumberParam({
+ params: element.params,
+ key: "letterSpacing",
+ fallback: DEFAULTS.text.letterSpacing,
+ }),
+ lineHeight: readNumberParam({
+ params: element.params,
+ key: "lineHeight",
+ fallback: DEFAULTS.text.lineHeight,
+ }),
+ };
+}
+
+export function buildTextBackgroundFromElement({
+ element,
+}: {
+ element: TextElement;
+}): TextBackground {
+ return {
+ enabled: readBooleanParam({
+ params: element.params,
+ key: "background.enabled",
+ fallback: DEFAULTS.text.background.enabled,
+ }),
+ color: readStringParam({
+ params: element.params,
+ key: "background.color",
+ fallback: DEFAULTS.text.background.color,
+ }),
+ cornerRadius: readNumberParam({
+ params: element.params,
+ key: "background.cornerRadius",
+ fallback: DEFAULTS.text.background.cornerRadius,
+ }),
+ paddingX: readNumberParam({
+ params: element.params,
+ key: "background.paddingX",
+ fallback: DEFAULTS.text.background.paddingX,
+ }),
+ paddingY: readNumberParam({
+ params: element.params,
+ key: "background.paddingY",
+ fallback: DEFAULTS.text.background.paddingY,
+ }),
+ offsetX: readNumberParam({
+ params: element.params,
+ key: "background.offsetX",
+ fallback: DEFAULTS.text.background.offsetX,
+ }),
+ offsetY: readNumberParam({
+ params: element.params,
+ key: "background.offsetY",
+ fallback: DEFAULTS.text.background.offsetY,
+ }),
+ };
+}
+
+function readStringParam({
+ params,
+ key,
+ fallback,
+}: {
+ params: TextElement["params"];
+ key: string;
+ fallback: string;
+}): string {
+ const value = params[key];
+ return typeof value === "string" ? value : fallback;
+}
+
+function readNumberParam({
+ params,
+ key,
+ fallback,
+}: {
+ params: TextElement["params"];
+ key: string;
+ fallback: number;
+}): number {
+ const value = params[key];
+ return typeof value === "number" ? value : fallback;
+}
+
+function readBooleanParam({
+ params,
+ key,
+ fallback,
+}: {
+ params: TextElement["params"];
+ key: string;
+ fallback: boolean;
+}): boolean {
+ const value = params[key];
+ return typeof value === "boolean" ? value : fallback;
+}
+
+function readTextAlign({
+ value,
+ fallback,
+}: {
+ value: unknown;
+ fallback: TextAlign;
+}): TextAlign {
+ return value === "left" || value === "center" || value === "right"
+ ? value
+ : fallback;
+}
+
+function readFontWeight({
+ value,
+ fallback,
+}: {
+ value: unknown;
+ fallback: TextFontWeight;
+}): TextFontWeight {
+ return value === "bold" || value === "normal" ? value : fallback;
+}
+
+function readFontStyle({
+ value,
+ fallback,
+}: {
+ value: unknown;
+ fallback: TextFontStyle;
+}): TextFontStyle {
+ return value === "italic" || value === "normal" ? value : fallback;
+}
+
+function readTextDecoration({
+ value,
+ fallback,
+}: {
+ value: unknown;
+ fallback: TextDecoration;
+}): TextDecoration {
+ return value === "none" || value === "underline" || value === "line-through"
+ ? value
+ : fallback;
+}
diff --git a/apps/web/src/text/primitives.ts b/apps/web/src/text/primitives.ts
index 9a9262a4..b2eefa95 100644
--- a/apps/web/src/text/primitives.ts
+++ b/apps/web/src/text/primitives.ts
@@ -85,7 +85,7 @@ export function resolveTextLayout({
const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing;
const lineHeightPx =
scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight);
- const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize;
+ const fontSizeRatio = text.fontSize / 15;
return {
scaledFontSize,
diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts
new file mode 100644
index 00000000..40a0e645
--- /dev/null
+++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, test } from "bun:test";
+import type { Transform } from "@/rendering";
+import type { SceneTracks, VideoElement } from "@/timeline";
+import { applyElementUpdate } from "@/timeline/update-pipeline";
+import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
+
+function buildTransform(): Transform {
+ return {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ };
+}
+
+function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement {
+ return {
+ id: "video-1",
+ type: "video",
+ name: "Video 1",
+ startTime: ZERO_MEDIA_TIME,
+ duration: mediaTime({ ticks: 10 }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ mediaId: "media-1",
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
+ ...overrides,
+ };
+}
+
+function buildTracks(element: VideoElement): SceneTracks {
+ return {
+ overlay: [],
+ main: {
+ id: "main-track",
+ type: "video",
+ name: "Main",
+ muted: false,
+ hidden: false,
+ elements: [element],
+ },
+ audio: [],
+ };
+}
+
+describe("applyElementUpdate", () => {
+ test("rounds retimed durations back to integer media time", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks(element);
+
+ const updatedElement = applyElementUpdate({
+ element,
+ patch: {
+ retime: { rate: 1.5 },
+ },
+ context: {
+ tracks,
+ trackId: tracks.main.id,
+ },
+ });
+
+ expect(updatedElement.duration).toBe(7);
+ expect(Number.isInteger(updatedElement.duration)).toBe(true);
+ });
+});
diff --git a/apps/web/src/timeline/animation-properties.ts b/apps/web/src/timeline/animation-properties.ts
deleted file mode 100644
index 31479a58..00000000
--- a/apps/web/src/timeline/animation-properties.ts
+++ /dev/null
@@ -1,425 +0,0 @@
-import type {
- AnimationBindingKind,
- AnimationInterpolation,
- AnimationPropertyPath,
- AnimationValue,
- ElementAnimations,
- NumericSpec,
-} from "@/animation/types";
-import { upsertPathKeyframe } from "@/animation";
-import { parseColorToLinearRgba } from "@/animation/binding-values";
-import { isAnimationPropertyPath } from "@/animation/path";
-import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
-import { snapToStep } from "@/utils/math";
-import type { TimelineElement } from "@/timeline";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
-} from "@/text/background";
-import {
- canElementHaveAudio,
- isVisualElement,
-} from "@/timeline/element-utils";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
-import { DEFAULTS } from "@/timeline/defaults";
-
-export interface AnimationPropertyDefinition {
- kind: AnimationBindingKind;
- defaultInterpolation: AnimationInterpolation;
- numericRanges?: Partial<Record<string, NumericSpec>>;
- supportsElement: ({ element }: { element: TimelineElement }) => boolean;
- getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
- coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
- setValue: ({
- element,
- value,
- }: {
- element: TimelineElement;
- value: AnimationValue;
- }) => TimelineElement;
-}
-
-function applyNumericSpec({
- value,
- numericRange,
-}: {
- value: number;
- numericRange: NumericSpec | undefined;
-}): number {
- if (!numericRange) {
- return value;
- }
-
- const steppedValue =
- numericRange.step != null
- ? snapToStep({ value, step: numericRange.step })
- : value;
- const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
- const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, steppedValue));
-}
-
-function coerceNumberValue({
- value,
- numericRange,
-}: {
- value: AnimationValue;
- numericRange?: NumericSpec;
-}): number | null {
- if (typeof value !== "number" || Number.isNaN(value)) {
- return null;
- }
-
- return applyNumericSpec({ value, numericRange });
-}
-
-function coerceColorValue({
- value,
-}: {
- value: AnimationValue;
-}): string | null {
- return typeof value === "string" && parseColorToLinearRgba({ color: value })
- ? value
- : null;
-}
-
-function createNumberPropertyDefinition({
- numericRange,
- supportsElement,
- getValue,
- setValue,
-}: {
- numericRange?: NumericSpec;
- supportsElement: AnimationPropertyDefinition["supportsElement"];
- getValue: AnimationPropertyDefinition["getValue"];
- setValue: AnimationPropertyDefinition["setValue"];
-}): AnimationPropertyDefinition {
- return {
- kind: "number",
- defaultInterpolation: "linear",
- numericRanges: numericRange ? { value: numericRange } : undefined,
- supportsElement,
- getValue,
- coerceValue: ({ value }) =>
- coerceNumberValue({
- value,
- numericRange,
- }),
- setValue,
- };
-}
-
-const ANIMATION_PROPERTY_REGISTRY: Record<
- AnimationPropertyPath,
- AnimationPropertyDefinition
-> = {
- "transform.positionX": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position.x : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: {
- ...element.transform,
- position: { ...element.transform.position, x: value as number },
- },
- }
- : element,
- }),
- "transform.positionY": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position.y : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: {
- ...element.transform,
- position: { ...element.transform.position, y: value as number },
- },
- }
- : element,
- }),
- "transform.scaleX": createNumberPropertyDefinition({
- numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scaleX : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, scaleX: value as number },
- }
- : element,
- }),
- "transform.scaleY": createNumberPropertyDefinition({
- numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scaleY : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, scaleY: value as number },
- }
- : element,
- }),
- "transform.rotate": createNumberPropertyDefinition({
- numericRange: { min: -360, max: 360, step: 1 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.rotate : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? {
- ...element,
- transform: { ...element.transform, rotate: value as number },
- }
- : element,
- }),
- opacity: createNumberPropertyDefinition({
- numericRange: { min: 0, max: 1, step: 0.01 },
- supportsElement: ({ element }) => isVisualElement(element),
- getValue: ({ element }) =>
- isVisualElement(element) ? element.opacity : null,
- setValue: ({ element, value }) =>
- isVisualElement(element)
- ? { ...element, opacity: value as number }
- : element,
- }),
- volume: createNumberPropertyDefinition({
- numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
- supportsElement: ({ element }) => canElementHaveAudio(element),
- getValue: ({ element }) =>
- canElementHaveAudio(element) ? element.volume ?? 0 : null,
- setValue: ({ element, value }) =>
- canElementHaveAudio(element)
- ? { ...element, volume: value as number }
- : element,
- }),
- color: {
- kind: "color",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) => (element.type === "text" ? element.color : null),
- coerceValue: ({ value }) => coerceColorValue({ value }),
- setValue: ({ element, value }) =>
- element.type === "text"
- ? { ...element, color: value as string }
- : element,
- },
- "background.color": {
- kind: "color",
- defaultInterpolation: "linear",
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text" ? element.background.color : null,
- coerceValue: ({ value }) => coerceColorValue({ value }),
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, color: value as string },
- }
- : element,
- },
- "background.paddingX": createNumberPropertyDefinition({
- numericRange: { min: 0, step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, paddingX: value as number },
- }
- : element,
- }),
- "background.paddingY": createNumberPropertyDefinition({
- numericRange: { min: 0, step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, paddingY: value as number },
- }
- : element,
- }),
- "background.offsetX": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, offsetX: value as number },
- }
- : element,
- }),
- "background.offsetY": createNumberPropertyDefinition({
- numericRange: { step: 1 },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, offsetY: value as number },
- }
- : element,
- }),
- "background.cornerRadius": createNumberPropertyDefinition({
- numericRange: {
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- step: 1,
- },
- supportsElement: ({ element }) => element.type === "text",
- getValue: ({ element }) =>
- element.type === "text"
- ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
- : null,
- setValue: ({ element, value }) =>
- element.type === "text"
- ? {
- ...element,
- background: { ...element.background, cornerRadius: value as number },
- }
- : element,
- }),
-};
-
-export function 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 });
-}
-
-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,
- }),
- });
-}
diff --git a/apps/web/src/timeline/animation-snap-points.ts b/apps/web/src/timeline/animation-snap-points.ts
index f7bf549a..e64de11f 100644
--- a/apps/web/src/timeline/animation-snap-points.ts
+++ b/apps/web/src/timeline/animation-snap-points.ts
@@ -1,6 +1,7 @@
import { getElementKeyframes } from "@/animation";
import type { SceneTracks } from "@/timeline";
import type { SnapPoint } from "@/timeline/snapping";
+import { addMediaTime } from "@/wasm";
export function getAnimationKeyframeSnapPointsForTimeline({
tracks,
@@ -22,7 +23,7 @@ export function getAnimationKeyframeSnapPointsForTimeline({
animations: element.animations,
})) {
snapPoints.push({
- time: element.startTime + keyframe.time,
+ time: addMediaTime({ a: element.startTime, b: keyframe.time }),
type: "keyframe",
elementId: element.id,
trackId: track.id,
diff --git a/apps/web/src/timeline/animation-targets.ts b/apps/web/src/timeline/animation-targets.ts
index f9d53fcc..4ee764b6 100644
--- a/apps/web/src/timeline/animation-targets.ts
+++ b/apps/web/src/timeline/animation-targets.ts
@@ -1,17 +1,9 @@
import type {
- AnimationBindingKind,
AnimationInterpolation,
AnimationPath,
- AnimationValue,
NumericSpec,
} from "@/animation/types";
import {
- coerceAnimationParamValue,
- getAnimationParamDefaultInterpolation,
- getAnimationParamNumericRange,
- getAnimationParamValueKind,
-} from "@/animation/animated-params";
-import {
parseEffectParamPath,
} from "@/animation/effect-param-channel";
import {
@@ -19,38 +11,97 @@ import {
} from "@/animation/graphic-param-channel";
import { effectsRegistry, registerDefaultEffects } from "@/effects";
import { getGraphicDefinition } from "@/graphics";
-import type { ParamDefinition } from "@/params";
+import {
+ coerceParamValue,
+ getParamDefaultInterpolation,
+ getParamChannelLayout,
+ getParamNumericRange,
+ type ParamChannelLayout,
+ type ParamDefinition,
+ type ParamValue,
+ type ParamValues,
+} from "@/params";
+import {
+ getElementParam,
+} from "@/params/registry";
import type { TimelineElement } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils";
-import { isAnimationPropertyPath } from "@/animation/path";
-import {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getElementBaseValueForProperty,
- withElementBaseValueForProperty,
-} from "./animation-properties";
export interface AnimationPathDescriptor {
- kind: AnimationBindingKind;
+ channelLayout: ParamChannelLayout;
defaultInterpolation: AnimationInterpolation;
numericRanges?: Partial<Record<string, NumericSpec>>;
- coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
- getBaseValue: () => AnimationValue | null;
- setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
+ coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null;
+ getBaseValue: () => ParamValue | null;
+ setBaseValue: ({ value }: { value: ParamValue }) => TimelineElement;
}
-// Number/discrete bindings expose a single component named "value"
-// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry
-// numeric ranges yet — revisit when one does.
+// Leaf params expose a single component named "value". Composite params don't
+// carry numeric ranges yet — revisit when one does.
function paramNumericRanges({
param,
}: {
param: ParamDefinition;
}): Partial<Record<string, NumericSpec>> | undefined {
- const range = getAnimationParamNumericRange({ param });
+ const range = getParamNumericRange({ param });
return range ? { value: range } : undefined;
}
+function buildParamDescriptor({
+ param,
+ baseParams,
+ setParams,
+}: {
+ param: ParamDefinition;
+ baseParams: ParamValues;
+ setParams: (params: ParamValues) => TimelineElement;
+}): AnimationPathDescriptor | null {
+ if (param.keyframable === false) {
+ return null;
+ }
+
+ return {
+ channelLayout: getParamChannelLayout({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRanges: paramNumericRanges({ param }),
+ coerceValue: ({ value }) => coerceParamValue({ param, value }),
+ getBaseValue: () => baseParams[param.key] ?? param.default,
+ setBaseValue: ({ value }) => {
+ const coercedValue = coerceParamValue({ param, value });
+ if (coercedValue === null) {
+ return setParams(baseParams);
+ }
+
+ return setParams({
+ ...baseParams,
+ [param.key]: coercedValue,
+ });
+ },
+ };
+}
+
+function buildElementParamDescriptor({
+ element,
+ paramKey,
+}: {
+ element: TimelineElement;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ const param = getElementParam({ element, key: paramKey });
+ if (!param) {
+ return null;
+ }
+
+ return buildParamDescriptor({
+ param,
+ baseParams: element.params,
+ setParams: (params) => ({
+ ...element,
+ params,
+ }),
+ });
+}
+
function buildGraphicParamDescriptor({
element,
paramKey,
@@ -70,27 +121,14 @@ function buildGraphicParamDescriptor({
return null;
}
- return {
- kind: getAnimationParamValueKind({ param }),
- defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
- numericRanges: paramNumericRanges({ param }),
- coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
- getBaseValue: () => element.params[param.key] ?? param.default,
- setBaseValue: ({ value }) => {
- const coercedValue = coerceAnimationParamValue({ param, value });
- if (coercedValue === null) {
- return element;
- }
-
- return {
- ...element,
- params: {
- ...element.params,
- [param.key]: coercedValue,
- },
- };
- },
- };
+ return buildParamDescriptor({
+ param,
+ baseParams: element.params,
+ setParams: (params) => ({
+ ...element,
+ params,
+ }),
+ });
}
function buildEffectParamDescriptor({
@@ -118,35 +156,22 @@ function buildEffectParamDescriptor({
return null;
}
- return {
- kind: getAnimationParamValueKind({ param }),
- defaultInterpolation: getAnimationParamDefaultInterpolation({ param }),
- numericRanges: paramNumericRanges({ param }),
- coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }),
- getBaseValue: () => effect.params[param.key] ?? param.default,
- setBaseValue: ({ value }) => {
- const coercedValue = coerceAnimationParamValue({ 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,
- };
- },
- };
+ return buildParamDescriptor({
+ param,
+ baseParams: effect.params,
+ setParams: (params) => ({
+ ...element,
+ effects:
+ element.effects?.map((candidate) =>
+ candidate.id !== effectId
+ ? candidate
+ : {
+ ...candidate,
+ params,
+ },
+ ) ?? element.effects,
+ }),
+ });
}
export function resolveAnimationTarget({
@@ -156,41 +181,12 @@ export function resolveAnimationTarget({
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 elementParamTarget = buildElementParamDescriptor({
+ element,
+ paramKey: path,
+ });
+ if (elementParamTarget) {
+ return elementParamTarget;
}
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
diff --git a/apps/web/src/timeline/audio-separation/index.ts b/apps/web/src/timeline/audio-separation/index.ts
index beb488de..6917378c 100644
--- a/apps/web/src/timeline/audio-separation/index.ts
+++ b/apps/web/src/timeline/audio-separation/index.ts
@@ -87,8 +87,13 @@ export function buildSeparatedAudioElement({
trimStart: sourceElement.trimStart,
trimEnd: sourceElement.trimEnd,
sourceDuration: sourceElement.sourceDuration,
- volume: sourceElement.volume ?? DEFAULTS.element.volume,
- muted: sourceElement.muted ?? false,
+ params: {
+ volume:
+ typeof sourceElement.params.volume === "number"
+ ? sourceElement.params.volume
+ : DEFAULTS.element.volume,
+ muted: sourceElement.params.muted === true,
+ },
retime: sourceElement.retime
? {
rate: sourceElement.retime.rate,
@@ -116,28 +121,13 @@ function cloneVolumeAnimations({
}: {
animations: ElementAnimations | undefined;
}): ElementAnimations | undefined {
- const volumeBinding = animations?.bindings.volume;
- if (!volumeBinding) {
- return undefined;
- }
-
- const subsetChannels = Object.fromEntries(
- volumeBinding.components.flatMap((component) => {
- const channel = animations?.channels[component.channelId];
- return channel ? [[component.channelId, channel] as const] : [];
- }),
- );
- if (Object.keys(subsetChannels).length === 0) {
+ const volumeData = animations?.volume;
+ if (!volumeData) {
return undefined;
}
return cloneAnimations({
- animations: {
- bindings: {
- volume: volumeBinding,
- },
- channels: subsetChannels,
- },
+ animations: { volume: volumeData },
shouldRegenerateKeyframeIds: true,
});
}
diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts
index 44df5aaf..6c8f3044 100644
--- a/apps/web/src/timeline/audio-state.ts
+++ b/apps/web/src/timeline/audio-state.ts
@@ -21,6 +21,23 @@ export function dBToLinear(db: number): number {
return 10 ** (clampDb(db) / 20);
}
+export function getElementVolume({
+ element,
+}: {
+ element: AudioCapableElement;
+}): number {
+ const value = element.params.volume;
+ return typeof value === "number" ? value : 0;
+}
+
+export function isElementMuted({
+ element,
+}: {
+ element: AudioCapableElement;
+}): boolean {
+ return element.params.muted === true;
+}
+
export function hasAnimatedVolume({
element,
}: {
@@ -43,12 +60,12 @@ export function resolveEffectiveAudioGain({
trackMuted?: boolean;
localTime: number;
}): number {
- if (trackMuted || element.muted === true) {
+ if (trackMuted || isElementMuted({ element })) {
return 0;
}
const resolvedDb = resolveNumberAtTime({
- baseValue: element.volume ?? 0,
+ baseValue: getElementVolume({ element }),
animations: element.animations,
propertyPath: "volume",
localTime: Math.round(localTime * TICKS_PER_SECOND),
diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx
index 20e22c57..43ba4ffc 100644
--- a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx
+++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx
@@ -7,7 +7,6 @@ import type { BookmarkDragState } from "../hooks/use-bookmark-drag";
import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme";
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout";
import { DEFAULT_FPS } from "@/fps/defaults";
-import { snappedSeekTime } from "opencut-wasm";
import {
ArrowTurnBackwardIcon,
Delete02Icon,
@@ -26,6 +25,14 @@ import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string";
import { clamp, formatNumberForDisplay } from "@/utils/math";
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline";
+import {
+ type MediaTime,
+ mediaTimeFromSeconds,
+ mediaTimeToSeconds,
+ snapSeekMediaTime,
+ subMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
@@ -39,12 +46,12 @@ function seekToBookmarkTime({
time,
}: {
editor: EditorCore;
- time: number;
+ time: MediaTime;
}) {
const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
- const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
+ const snappedTime = snapSeekMediaTime({ time, duration, fps: rate });
editor.playback.seek({ time: snappedTime });
}
@@ -137,7 +144,7 @@ function TimelineBookmark({
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
const time = bookmark.time;
- const bookmarkDuration = bookmark.duration ?? 0;
+ const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME;
const durationWidth =
bookmarkDuration > 0
? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
@@ -182,7 +189,7 @@ function TimelineBookmark({
left: `${bookmarkLeft}px`,
width: `${bookmarkWidth}px`,
}}
- aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
+ aria-label={`Bookmark at ${formatNumberForDisplay({ value: mediaTimeToSeconds({ time }), fractionDigits: 1 })}s`}
type="button"
onMouseDown={handleMouseDown}
onClick={handleClick}
@@ -286,8 +293,8 @@ function BookmarkPopoverContent({
onPopoverClose,
}: {
bookmark: Bookmark;
- time: number;
- timelineDuration: number;
+ time: MediaTime;
+ timelineDuration: MediaTime;
onPopoverClose: () => void;
}) {
const editor = useEditor();
@@ -314,9 +321,8 @@ function BookmarkPopoverContent({
note,
color,
duration,
- }: Partial<{ note: string; color: string; duration: number }>) => {
- const updates: Partial<{ note: string; color: string; duration: number }> =
- {};
+ }: Partial<Omit<Bookmark, "time">>) => {
+ const updates: Partial<Omit<Bookmark, "time">> = {};
if (note !== undefined && note !== bookmark.note) updates.note = note;
if (
color !== undefined &&
@@ -330,6 +336,15 @@ function BookmarkPopoverContent({
if (Object.keys(updates).length === 0) return;
editor.scenes.updateBookmark({ time, updates });
};
+ const maxDuration = mediaTimeToSeconds({
+ time:
+ timelineDuration > time
+ ? subMediaTime({ a: timelineDuration, b: time })
+ : ZERO_MEDIA_TIME,
+ });
+ const durationSeconds = mediaTimeToSeconds({
+ time: bookmark.duration ?? ZERO_MEDIA_TIME,
+ });
return (
<>
@@ -387,7 +402,7 @@ function BookmarkPopoverContent({
type="number"
min={0}
step={0.1}
- value={bookmark.duration ?? 0}
+ value={durationSeconds}
onChange={(event) => {
const parsed = parseFloat(event.target.value);
const value = Number.isNaN(parsed)
@@ -395,9 +410,11 @@ function BookmarkPopoverContent({
: clamp({
value: parsed,
min: 0,
- max: Math.max(0, timelineDuration - time),
+ max: maxDuration,
});
- handleUpdate({ duration: value });
+ handleUpdate({
+ duration: mediaTimeFromSeconds({ seconds: value }),
+ });
}}
className="h-8 text-sm"
containerClassName="w-full"
diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
index 35ee9cca..4c03ef8c 100644
--- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
+++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
@@ -8,7 +8,6 @@ import {
import { useEditor } from "@/editor/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
-import { roundToFrame } from "opencut-wasm";
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
import {
buildTimelineSnapPoints,
@@ -21,15 +20,16 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
import type { Bookmark } from "@/timeline";
+import { roundFrameTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export interface BookmarkDragState {
isDragging: boolean;
- bookmarkTime: number | null;
- currentTime: number;
+ bookmarkTime: MediaTime | null;
+ currentTime: MediaTime;
}
interface PendingBookmarkDrag {
- bookmarkTime: number;
+ bookmarkTime: MediaTime;
startMouseX: number;
startMouseY: number;
}
@@ -58,7 +58,7 @@ export function useBookmarkDrag({
const [dragState, setDragState] = useState<BookmarkDragState>({
isDragging: false,
bookmarkTime: null,
- currentTime: 0,
+ currentTime: ZERO_MEDIA_TIME,
});
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
@@ -69,8 +69,8 @@ export function useBookmarkDrag({
bookmarkTime,
initialCurrentTime,
}: {
- bookmarkTime: number;
- initialCurrentTime: number;
+ bookmarkTime: MediaTime;
+ initialCurrentTime: MediaTime;
}) => {
setDragState({
isDragging: true,
@@ -85,7 +85,7 @@ export function useBookmarkDrag({
setDragState({
isDragging: false,
bookmarkTime: null,
- currentTime: 0,
+ currentTime: ZERO_MEDIA_TIME,
});
}, []);
@@ -94,9 +94,9 @@ export function useBookmarkDrag({
rawTime,
excludeBookmarkTime,
}: {
- rawTime: number;
- excludeBookmarkTime: number;
- }): { snappedTime: number; snapPoint: SnapPoint | null } => {
+ rawTime: MediaTime;
+ excludeBookmarkTime: MediaTime;
+ }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } => {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (!shouldSnap) {
return { snappedTime: rawTime, snapPoint: null };
@@ -162,11 +162,12 @@ export function useBookmarkDrag({
zoomLevel,
scrollLeft,
});
- const frameSnappedTime =
- roundToFrame({
- time: Math.max(0, Math.min(mouseTime, duration)),
- rate: activeProject.settings.fps,
- }) ?? Math.max(0, Math.min(mouseTime, duration));
+ const clampedTime =
+ mouseTime > duration ? duration : mouseTime;
+ const frameSnappedTime = roundFrameTime({
+ time: clampedTime,
+ fps: activeProject.settings.fps,
+ });
const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime,
@@ -193,10 +194,12 @@ export function useBookmarkDrag({
zoomLevel,
scrollLeft,
});
- const clampedTime = Math.max(0, Math.min(mouseTime, duration));
- const frameSnappedTime =
- roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ??
- clampedTime;
+ const clampedTime =
+ mouseTime > duration ? duration : mouseTime;
+ const frameSnappedTime = roundFrameTime({
+ time: clampedTime,
+ fps: activeProject.settings.fps,
+ });
const snapResult = getSnapResult({
rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime,
@@ -234,10 +237,8 @@ export function useBookmarkDrag({
return;
}
- const clampedTime = Math.max(
- 0,
- Math.min(dragState.currentTime, duration),
- );
+ const clampedTime =
+ dragState.currentTime > duration ? duration : dragState.currentTime;
editor.scenes.moveBookmark({
fromTime: dragState.bookmarkTime,
diff --git a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx
index 8e2b4bcf..2c254c84 100644
--- a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx
+++ b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx
@@ -5,6 +5,7 @@ import {
type PreviewOverlaySourceResult,
} from "@/preview/overlays";
import { getBookmarksActiveAtTime } from "./utils";
+import type { MediaTime } from "@/wasm";
export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
id: "bookmark-notes",
@@ -15,7 +16,7 @@ export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
function BookmarkNotesOverlay({
bookmarks,
}: {
- bookmarks: Array<{ time: number; note: string; color?: string }>;
+ bookmarks: Array<{ time: MediaTime; note: string; color?: string }>;
}) {
return (
<div className="flex flex-col gap-1.5" aria-live="polite">
@@ -43,7 +44,7 @@ export function getBookmarkPreviewOverlaySource({
isVisible,
}: {
bookmarks: Bookmark[];
- time: number;
+ time: MediaTime;
isVisible: boolean;
}): PreviewOverlaySourceResult {
const bookmarksWithNotes = getBookmarksActiveAtTime({
diff --git a/apps/web/src/timeline/bookmarks/snap-source.ts b/apps/web/src/timeline/bookmarks/snap-source.ts
index 32a84baa..64d0c3c3 100644
--- a/apps/web/src/timeline/bookmarks/snap-source.ts
+++ b/apps/web/src/timeline/bookmarks/snap-source.ts
@@ -1,12 +1,13 @@
import type { Bookmark } from "@/timeline";
import type { SnapPoint } from "@/timeline/snapping";
+import type { MediaTime } from "@/wasm";
export function getBookmarkSnapPoints({
bookmarks,
excludeBookmarkTime,
}: {
bookmarks: Bookmark[];
- excludeBookmarkTime?: number;
+ excludeBookmarkTime?: MediaTime;
}): SnapPoint[] {
return bookmarks.flatMap((bookmark) => {
if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) {
diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts
index d05fe3a9..702e66c3 100644
--- a/apps/web/src/timeline/bookmarks/utils.ts
+++ b/apps/web/src/timeline/bookmarks/utils.ts
@@ -1,13 +1,13 @@
import type { Bookmark } from "@/timeline";
import type { FrameRate } from "opencut-wasm";
-import { roundToFrame } from "opencut-wasm";
+import { addMediaTime, roundFrameTime, type MediaTime } from "@/wasm";
function bookmarkTimeEqual({
bookmarkTime,
frameTime,
}: {
- bookmarkTime: number;
- frameTime: number;
+ bookmarkTime: MediaTime;
+ frameTime: MediaTime;
}): boolean {
return bookmarkTime === frameTime;
}
@@ -17,7 +17,7 @@ export function findBookmarkIndex({
frameTime,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
}): number {
return bookmarks.findIndex((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
@@ -29,7 +29,7 @@ export function isBookmarkAtTime({
frameTime,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
}): boolean {
return bookmarks.some((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
@@ -41,7 +41,7 @@ export function toggleBookmarkInArray({
frameTime,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
}): Bookmark[] {
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
@@ -58,7 +58,7 @@ export function removeBookmarkFromArray({
frameTime,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
}): Bookmark[] {
return bookmarks.filter(
(bookmark) =>
@@ -72,7 +72,7 @@ export function updateBookmarkInArray({
updates,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
updates: Partial<Omit<Bookmark, "time">>;
}): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime });
@@ -92,8 +92,8 @@ export function moveBookmarkInArray({
toTime,
}: {
bookmarks: Bookmark[];
- fromTime: number;
- toTime: number;
+ fromTime: MediaTime;
+ toTime: MediaTime;
}): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
if (index === -1) {
@@ -110,10 +110,10 @@ export function getFrameTime({
time,
fps,
}: {
- time: number;
+ time: MediaTime;
fps: FrameRate;
-}): number {
- return roundToFrame({ time, rate: fps }) ?? time;
+}): MediaTime {
+ return roundFrameTime({ time, fps });
}
export function getBookmarkAtTime({
@@ -121,7 +121,7 @@ export function getBookmarkAtTime({
frameTime,
}: {
bookmarks: Bookmark[];
- frameTime: number;
+ frameTime: MediaTime;
}): Bookmark | null {
const index = findBookmarkIndex({ bookmarks, frameTime });
return index === -1 ? null : bookmarks[index];
@@ -132,13 +132,13 @@ export function getBookmarksActiveAtTime({
time,
}: {
bookmarks: Bookmark[];
- time: number;
+ time: MediaTime;
}): Bookmark[] {
return bookmarks.filter((bookmark) => {
const start = bookmark.time;
const end =
bookmark.duration != null && bookmark.duration > 0
- ? start + bookmark.duration
+ ? addMediaTime({ a: start, b: bookmark.duration })
: start;
return time >= start && time <= end;
});
diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx
deleted file mode 100644
index 1e47dcc8..00000000
--- a/apps/web/src/timeline/components/audio-tab.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import { Button } from "@/components/ui/button";
-import { NumberField } from "@/components/ui/number-field";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
-import { isSourceAudioSeparated } from "@/timeline/audio-separation";
-import { DEFAULTS } from "@/timeline/defaults";
-import {
- clamp,
- formatNumberForDisplay,
- getFractionDigitsForStep,
- isNearlyEqual,
- snapToStep,
-} from "@/utils/math";
-import type { AudioElement, VideoElement } from "@/timeline";
-import { resolveNumberAtTime } from "@/animation/values";
-import { useEditor } from "@/editor/use-editor";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
-import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { VolumeHighIcon } from "@hugeicons/core-free-icons";
-import {
- Section,
- SectionContent,
- SectionField,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-
-const VOLUME_STEP = 0.1;
-const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
-
-export function AudioTab({
- element,
- trackId,
-}: {
- element: AudioElement | VideoElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const resolvedVolume = resolveNumberAtTime({
- baseValue: element.volume ?? DEFAULTS.element.volume,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- });
- const volume = useKeyframedNumberProperty({
- trackId,
- elementId: element.id,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- isPlayheadWithinElementRange,
- displayValue: formatNumberForDisplay({
- value: resolvedVolume,
- fractionDigits: VOLUME_FRACTION_DIGITS,
- }),
- parse: (input) => {
- const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) {
- return null;
- }
-
- return clamp({
- value: snapToStep({ value: parsed, step: VOLUME_STEP }),
- min: VOLUME_DB_MIN,
- max: VOLUME_DB_MAX,
- });
- },
- valueAtPlayhead: resolvedVolume,
- step: VOLUME_STEP,
- buildBaseUpdates: ({ value }) => ({
- volume: value,
- }),
- });
- const isDefault =
- volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
- ? isNearlyEqual({
- leftValue: resolvedVolume,
- rightValue: DEFAULTS.element.volume,
- })
- : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
- const isSeparated =
- element.type === "video" && isSourceAudioSeparated({ element });
-
- return (
- <>
- {isSeparated && (
- <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
- <p className="text-sm">Audio has been separated.</p>
- <Button
- className="mt-3"
- size="sm"
- variant="secondary"
- onClick={() =>
- editor.timeline.toggleSourceAudioSeparation({
- trackId,
- elementId: element.id,
- })
- }
- >
- Recover audio
- </Button>
- </div>
- )}
- <Section collapsible sectionKey={`${element.id}:audio`}>
- <SectionHeader>
- <SectionTitle>Audio</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- <SectionField
- label="Volume"
- beforeLabel={
- <KeyframeToggle
- isActive={volume.isKeyframedAtTime}
- isDisabled={!isPlayheadWithinElementRange}
- title="Toggle volume keyframe"
- onToggle={volume.toggleKeyframe}
- />
- }
- >
- <NumberField
- icon={<HugeiconsIcon icon={VolumeHighIcon} />}
- value={volume.displayValue}
- onFocus={volume.onFocus}
- onChange={volume.onChange}
- onBlur={volume.onBlur}
- dragSensitivity="slow"
- scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
- onScrub={volume.scrubTo}
- onScrubEnd={volume.commitScrub}
- onReset={() =>
- volume.commitValue({
- value: DEFAULTS.element.volume,
- })
- }
- isDefault={isDefault}
- suffix="dB"
- />
- </SectionField>
- </SectionFields>
- </SectionContent>
- </Section>
- </>
- );
-}
diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx
index 1baac2bd..659bc5dc 100644
--- a/apps/web/src/timeline/components/audio-volume-line.tsx
+++ b/apps/web/src/timeline/components/audio-volume-line.tsx
@@ -3,13 +3,12 @@
import { useCallback, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEditor } from "@/editor/use-editor";
-import { DEFAULTS } from "@/timeline/defaults";
import {
getDbFromLinePos,
getLinePosFromDb,
} from "@/timeline/audio-display";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
-import { hasAnimatedVolume } from "@/timeline/audio-state";
+import { getElementVolume, hasAnimatedVolume } from "@/timeline/audio-state";
import type { AudioElement } from "@/timeline/types";
import {
clamp,
@@ -60,10 +59,8 @@ export function AudioVolumeLine({
const editor = useEditor();
const surfaceRef = useRef<HTMLDivElement>(null);
const activePointerIdRef = useRef<number | null>(null);
- const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume);
- const lastPreviewVolumeRef = useRef(
- element.volume ?? DEFAULTS.element.volume,
- );
+ const startVolumeRef = useRef(getElementVolume({ element }));
+ const lastPreviewVolumeRef = useRef(getElementVolume({ element }));
const hasChangedRef = useRef(false);
const [isDragging, setIsDragging] = useState(false);
const [tooltipClientPos, setTooltipClientPos] = useState<{
@@ -72,7 +69,7 @@ export function AudioVolumeLine({
} | null>(null);
const hasAnimatedEnvelope = hasAnimatedVolume({ element });
- const currentVolume = element.volume ?? DEFAULTS.element.volume;
+ const currentVolume = getElementVolume({ element });
const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`;
const volumeLabel = `${formatNumberForDisplay({
@@ -96,7 +93,7 @@ export function AudioVolumeLine({
{
trackId,
elementId: element.id,
- updates: { volume: nextVolume },
+ updates: { params: { volume: nextVolume } },
},
],
});
@@ -242,8 +239,7 @@ export function AudioVolumeLine({
)}
style={{ top: lineTop }}
/>
- {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */}
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */}
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- custom drag widget for clip volume; pointer events drive the interaction, the click/mousedown handlers are propagation-stoppers. The a11y-correct long-term shape is <input type="range"> with a custom thumb. */}
<div
className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto"
style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }}
diff --git a/apps/web/src/timeline/components/audio-waveform.tsx b/apps/web/src/timeline/components/audio-waveform.tsx
index 7777bc09..87178f17 100644
--- a/apps/web/src/timeline/components/audio-waveform.tsx
+++ b/apps/web/src/timeline/components/audio-waveform.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useResizeObserver } from "@/hooks/use-resize-observer";
import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme";
import {
@@ -72,21 +73,19 @@ export function AudioWaveform({
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const summaryRef = useRef<SourceWaveformSummary | null>(null);
- const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
- const pixelsPerSecondRef = useRef<number>(pixelsPerSecond);
- const clipDurationSecRef = useRef<number>(clipDurationSec);
- const retimeRef = useRef<RetimeConfig | undefined>(retime);
- const sourceStartSecRef = useRef<number>(sourceStartSec);
+ const waveformConfigRef = useCommittedRef({
+ gainSamples,
+ pixelsPerSecond,
+ clipDurationSec,
+ retime,
+ sourceStartSec,
+ color,
+ burnColor,
+ });
const scrollParentRef = useRef<HTMLElement | null>(null);
const heightRef = useRef<number>(0);
const lastRenderSignatureRef = useRef<string | null>(null);
- gainSamplesRef.current = gainSamples;
- pixelsPerSecondRef.current = pixelsPerSecond;
- clipDurationSecRef.current = clipDurationSec;
- retimeRef.current = retime;
- sourceStartSecRef.current = sourceStartSec;
-
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) {
@@ -142,13 +141,19 @@ export function AudioWaveform({
return;
}
+ const {
+ gainSamples: gainSamplesValue,
+ pixelsPerSecond: pixelsPerSecondValue,
+ clipDurationSec: clipDurationSecValue,
+ retime: retimeValue,
+ sourceStartSec: sourceStartSecValue,
+ color: colorValue,
+ burnColor: burnColorValue,
+ } = waveformConfigRef.current;
const dpr = window.devicePixelRatio || 1;
const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr));
const canvasH = Math.max(1, Math.round(height * dpr));
const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
- const pixelsPerSecondValue = pixelsPerSecondRef.current;
- const clipDurationSecValue = clipDurationSecRef.current;
- const samples = gainSamplesRef.current;
const renderSignature = JSON.stringify({
elementWidth,
clipLeft,
@@ -159,16 +164,16 @@ export function AudioWaveform({
barCount,
dpr,
clipDurationSec: clipDurationSecValue,
- sourceStartSec: sourceStartSecRef.current,
+ sourceStartSec: sourceStartSecValue,
pixelsPerSecond: pixelsPerSecondValue,
- retime: retimeRef.current ?? null,
+ retime: retimeValue ?? null,
summarySourceKey: summary.sourceKey,
summarySampleRate: summary.sampleRate,
summaryTotalSamples: summary.totalSamples,
summaryBucketSize: summary.bucketSize,
- gainSamples: samples ?? null,
- color,
- burnColor,
+ gainSamples: gainSamplesValue ?? null,
+ color: colorValue,
+ burnColor: burnColorValue,
});
if (lastRenderSignatureRef.current === renderSignature) {
return;
@@ -190,8 +195,8 @@ export function AudioWaveform({
barCount,
pixelsPerSecond: pixelsPerSecondValue,
clipDurationSec: clipDurationSecValue,
- sourceStartSec: sourceStartSecRef.current,
- retime: retimeRef.current,
+ sourceStartSec: sourceStartSecValue,
+ retime: retimeValue,
sampleRate: summary.sampleRate,
maxSampleExclusive: summary.totalSamples,
barStepPx: BAR_STEP,
@@ -218,9 +223,9 @@ export function AudioWaveform({
Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue),
);
const gain =
- samples != null
+ gainSamplesValue != null
? sampleGainAtClipTime({
- samples,
+ samples: gainSamplesValue,
clipTimeSec: clipCenterSec,
clipDurationSec: clipDurationSecValue,
})
@@ -243,7 +248,7 @@ export function AudioWaveform({
const deviceTop = Math.round((height - barH) * backingScaleY);
const deviceHeight = Math.max(1, clipBottom - deviceTop);
- ctx.fillStyle = color;
+ ctx.fillStyle = colorValue;
ctx.fillRect(
deviceLeft,
deviceTop,
@@ -253,7 +258,7 @@ export function AudioWaveform({
if (outputAmplitude > 1) {
const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY));
- ctx.fillStyle = burnColor;
+ ctx.fillStyle = burnColorValue;
ctx.fillRect(
deviceLeft,
deviceTop,
@@ -262,7 +267,7 @@ export function AudioWaveform({
);
}
}
- }, [burnColor, clearCanvas, color]);
+ }, [clearCanvas, waveformConfigRef]);
useEffect(() => {
let isCancelled = false;
@@ -296,7 +301,6 @@ export function AudioWaveform({
};
}, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]);
- // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change.
useLayoutEffect(() => {
drawVisible();
}, [
@@ -306,6 +310,8 @@ export function AudioWaveform({
clipDurationSec,
retime,
sourceStartSec,
+ color,
+ burnColor,
]);
useEffect(() => {
diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts
index be2495e4..f68a4b72 100644
--- a/apps/web/src/timeline/components/drop-target.ts
+++ b/apps/web/src/timeline/components/drop-target.ts
@@ -3,7 +3,12 @@ import type { ComputeDropTargetParams, DropTarget } from "@/timeline";
import { resolveTrackPlacement } from "@/timeline/placement";
import { TIMELINE_TRACK_GAP_PX } from "./layout";
import { getTrackHeight } from "./track-layout";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ mediaTime,
+ type MediaTime,
+ roundMediaTime,
+ TICKS_PER_SECOND,
+} from "@/wasm";
function findElementAtPosition({
mouseX,
@@ -20,9 +25,11 @@ function findElementAtPosition({
pixelsPerSecond: number;
zoomLevel: number;
}): { elementId: string; trackId: string } | null {
- const time = Math.round(
- (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
- );
+ const time = mediaTime({
+ ticks: Math.round(
+ (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
+ ),
+ });
const track = tracks[trackIndex];
if (!track || !("elements" in track)) return null;
@@ -82,7 +89,7 @@ const EMPTY_TARGET_ELEMENT = null;
function fallbackNewTrackDropTarget({
xPosition,
}: {
- xPosition: number;
+ xPosition: MediaTime;
}): DropTarget {
return {
trackIndex: 0,
@@ -111,13 +118,16 @@ export function computeDropTarget({
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const mainTrackIndex = tracks.overlay.length;
const xPosition =
- typeof startTimeOverride === "number"
+ startTimeOverride !== undefined
? startTimeOverride
: isExternalDrop
? playheadTime
- : Math.round(
- Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
- );
+ : mediaTime({
+ ticks: Math.round(
+ Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) *
+ TICKS_PER_SECOND,
+ ),
+ });
if (orderedTracks.length === 0) {
const placementResult = resolveTrackPlacement({
@@ -221,7 +231,10 @@ export function computeDropTarget({
}
if (placementResult.kind === "existingTrack") {
- const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
+ const adjustedXPosition =
+ placementResult.adjustedStartTime !== undefined
+ ? roundMediaTime({ time: placementResult.adjustedStartTime })
+ : xPosition;
return {
trackIndex: placementResult.trackIndex,
diff --git a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx
index 7d77b985..ff4219c5 100644
--- a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx
+++ b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx
@@ -2,6 +2,7 @@
import { useRef, useState, type PointerEvent } from "react";
import { useShiftKey } from "@/hooks/use-shift-key";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { getBezierPoint } from "@/animation/bezier";
import type { NormalizedCubicBezier } from "@/animation/types";
import { cn } from "@/utils/ui";
@@ -87,9 +88,7 @@ export function BezierGraph({
const svgRef = useRef<SVGSVGElement>(null);
const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null);
const isShiftPressedRef = useShiftKey();
- const latestValueRef = useRef(value);
-
- latestValueRef.current = value;
+ const latestValueRef = useCommittedRef(value);
function getPointerPosition({
event,
diff --git a/apps/web/src/timeline/components/graph-editor/session.ts b/apps/web/src/timeline/components/graph-editor/session.ts
index d7c6b001..c5d1c133 100644
--- a/apps/web/src/timeline/components/graph-editor/session.ts
+++ b/apps/web/src/timeline/components/graph-editor/session.ts
@@ -1,11 +1,12 @@
import {
getCurveHandlesForNormalizedCubicBezier,
getEditableScalarChannels,
- getEasingModeForKind,
getNormalizedCubicBezierForScalarSegment,
getScalarKeyframeContext,
updateScalarKeyframeCurve,
} from "@/animation";
+import { getChannelsFromData } from "@/animation/channel-data";
+import { isScalarChannel } from "@/animation/interpolation";
import type {
AnimationPath,
ElementAnimations,
@@ -143,12 +144,9 @@ function findKeyframeTime({
propertyPath: AnimationPath;
keyframeId: string;
}): number | null {
- const binding = animations.bindings[propertyPath];
- if (!binding) return null;
-
- for (const component of binding.components) {
- const channel = animations.channels[component.channelId];
- if (channel?.kind !== "scalar") continue;
+ const data = animations[propertyPath];
+ for (const channel of getChannelsFromData({ data })) {
+ if (!channel || !isScalarChannel(channel)) continue;
const key = channel.keys.find((k) => k.id === keyframeId);
if (key !== undefined) return key.time;
}
@@ -300,8 +298,7 @@ function resolvePropertySelection({
}
}
- const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
- const easingMode = getEasingModeForKind(resolvedBinding.kind);
+ const { easingMode, channels: scalarChannels } = scalarResult;
const contexts = scalarChannels.flatMap((channel) => {
const context = getScalarKeyframeContext({
animations: element.animations,
diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx
index 88fb93b7..8edbb6fc 100644
--- a/apps/web/src/timeline/components/index.tsx
+++ b/apps/web/src/timeline/components/index.tsx
@@ -29,6 +29,8 @@ import {
useState,
type ReactNode,
} from "react";
+import { useContainerSize } from "@/hooks/use-container-size";
+import type { MediaTime } from "@/wasm";
import type { ElementDragView, DropTarget } from "@/timeline";
import { TimelineTrackContent } from "./timeline-track";
import { TimelinePlayhead } from "./timeline-playhead";
@@ -79,6 +81,7 @@ import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bott
import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize";
import { useTimelineStore } from "@/timeline/timeline-store";
import { useEditor } from "@/editor/use-editor";
+import { useScrollPosition } from "@/timeline/hooks/use-scroll-position";
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
import { DragLine } from "./drag-line";
import { invokeAction } from "@/actions";
@@ -132,7 +135,7 @@ export function Timeline() {
[scene],
);
const mainTrackId = scene?.tracks.main.id ?? null;
- const seek = (time: number) => editor.playback.seek({ time });
+ const seek = (time: MediaTime) => editor.playback.seek({ time });
const timelineRef = useRef<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null);
@@ -147,15 +150,25 @@ export function Timeline() {
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null,
);
+ const { width: tracksContainerWidth } = useContainerSize({
+ containerRef: tracksContainerRef,
+ });
+ const { height: timelineHeaderHeightValue } = useContainerSize({
+ containerRef: timelineHeaderRef,
+ });
+ const { viewportWidth: tracksViewportWidth } = useScrollPosition({
+ scrollRef: tracksScrollRef,
+ });
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint);
}, []);
const timelineDuration = timeline.getTotalDuration() || 0;
+ const containerWidth = tracksContainerWidth || FALLBACK_CONTAINER_WIDTH;
const minZoomLevel = getTimelineZoomMin({
duration: timelineDuration,
- containerWidth: tracksContainerRef.current?.clientWidth,
+ containerWidth,
});
const savedViewState = editor.project.getTimelineViewState();
@@ -289,12 +302,12 @@ export function Timeline() {
const { dragView, handleElementMouseDown, handleElementClick } =
useElementInteraction({
- zoomLevel,
- tracksContainerRef,
- tracksScrollRef,
- snappingEnabled,
- onSnapPointChange: handleSnapPointChange,
- });
+ zoomLevel,
+ tracksContainerRef,
+ tracksScrollRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
const isElementDragging = dragView.kind === "dragging";
const {
@@ -357,8 +370,6 @@ export function Timeline() {
},
});
- const containerWidth =
- tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth = timelineTimeToPixels({
time: timelineDuration,
zoomLevel,
@@ -372,11 +383,8 @@ export function Timeline() {
contentWidth + paddingPx,
containerWidth,
);
- const tracksViewportWidth =
- tracksScrollRef.current?.clientWidth ??
- tracksContainerRef.current?.clientWidth ??
- containerWidth;
- const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth;
+ const hasHorizontalScrollbar =
+ dynamicTimelineWidth > (tracksViewportWidth || containerWidth);
useEdgeAutoScroll({
isActive: bookmarkDragState.isDragging,
@@ -418,8 +426,7 @@ export function Timeline() {
});
const timelineHeaderHeight =
- (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
- TIMELINE_CONTENT_TOP_PADDING_PX || 0;
+ timelineHeaderHeightValue + TIMELINE_CONTENT_TOP_PADDING_PX;
return (
<section
@@ -449,10 +456,7 @@ export function Timeline() {
ref={tracksContainerRef}
>
<SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
+ bounds={selectionBox?.bounds ?? null}
/>
<DragLine
dropTarget={dropTarget}
@@ -508,8 +512,7 @@ export function Timeline() {
className="flex min-h-full flex-col"
style={{ width: `${dynamicTimelineWidth}px` }}
>
- {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (tracks container background); direct-target clicks here originate box-select or clear selection. Keyboard control is global timeline shortcuts. */}
<div
className="relative shrink-0"
style={{
@@ -777,8 +780,8 @@ function TimelineTrackRows({
const draggingElementIds = useMemo(
() =>
dragView.kind === "dragging"
- ? dragView.memberTimeOffsets
- : (null as ReadonlyMap<string, number> | null),
+ ? dragView.memberTimeOffsets
+ : (null as ReadonlyMap<string, MediaTime> | null),
[dragView],
);
const sortedTracks = useMemo(() => {
@@ -890,9 +893,10 @@ function TimelineGutter({
onMouseDown: (event: React.MouseEvent) => void;
onClick: (event: React.MouseEvent) => void;
}) {
- // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system
- // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system
- return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />;
+ return (
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (empty space below tracks); clicks here clear selection. Keyboard control is global timeline shortcuts.
+ <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />
+ );
}
function TrackIcon({ track }: { track: TimelineTrack }) {
diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx
index 35785332..1b549a15 100644
--- a/apps/web/src/timeline/components/timeline-element.tsx
+++ b/apps/web/src/timeline/components/timeline-element.tsx
@@ -31,6 +31,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
+import type { SelectionBoxBounds } from "@/selection/types";
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
@@ -46,10 +47,10 @@ import {
getSourceAudioActionLabel,
isSourceAudioSeparated,
} from "@/timeline/audio-separation";
-import { buildWaveformGainSamples } from "@/timeline/audio-state";
+import { buildWaveformGainSamples, isElementMuted } from "@/timeline/audio-state";
import { getTimelinePixelsPerSecond } from "@/timeline";
import { buildWaveformSourceKey } from "@/media/waveform-summary";
-import { TICKS_PER_SECOND } from "@/wasm/ticks";
+import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
import {
getActionDefinition,
type TAction,
@@ -96,7 +97,7 @@ const PixelsPerSecondContext = createContext<number | null>(null);
const THUMBNAIL_ASPECT_RATIO = 16 / 9;
interface KeyframeIndicator {
- time: number;
+ time: MediaTime;
offsetPx: number;
keyframes: SelectedKeyframeRef[];
}
@@ -112,11 +113,11 @@ export function buildKeyframeIndicator({
keyframe: ElementKeyframe;
trackId: string;
elementId: string;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
zoomLevel: number;
elementLeft: number;
}): {
- time: number;
+ time: MediaTime;
offsetPx: number;
keyframeRef: SelectedKeyframeRef;
} {
@@ -149,7 +150,7 @@ export function getKeyframeIndicators({
keyframes: ElementKeyframe[];
trackId: string;
elementId: string;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
zoomLevel: number;
elementLeft: number;
elementWidth: number;
@@ -158,7 +159,7 @@ export function getKeyframeIndicators({
return [];
}
- const keyframesByTime = new Map<number, KeyframeIndicator>();
+ const keyframesByTime = new Map<MediaTime, KeyframeIndicator>();
for (const keyframe of keyframes) {
const indicator = buildKeyframeIndicator({
keyframe,
@@ -185,7 +186,7 @@ export function getKeyframeIndicators({
}
export function getDisplayShortcut({ action }: { action: TAction }) {
- const { defaultShortcuts } = getActionDefinition({ action });
+ const defaultShortcuts = getActionDefinition({ action }).defaultShortcuts;
if (!defaultShortcuts?.length) {
return "";
}
@@ -206,14 +207,14 @@ interface TimelineElementProps {
track: TimelineTrack;
side: "left" | "right";
}) => void;
- onElementMouseDown: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onElementClick: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
+ onElementMouseDown: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ }) => void;
+ onElementClick: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ }) => void;
dragView: ElementDragView;
isDropTarget?: boolean;
}
@@ -263,7 +264,7 @@ export function TimelineElement({
: 0;
const elementStartTime =
isDragging && isBeingDragged
- ? dragView.currentTime + dragTimeOffset
+ ? addMediaTime({ a: dragView.currentTime, b: dragTimeOffset })
: renderElement.startTime;
const displayedStartTime = elementStartTime;
const displayedDuration = renderElement.duration;
@@ -334,7 +335,7 @@ export function TimelineElement({
}
};
- const isMuted = canElementHaveAudio(element) && element.muted === true;
+ const isMuted = canElementHaveAudio(element) && isElementMuted({ element });
const canToggleCurrentSourceAudio =
selectedElements.length === 1 &&
isCurrentElementSelected &&
@@ -385,7 +386,7 @@ export function TimelineElement({
? `${baseTrackHeight + expansionHeight}px`
: "100%",
transform:
- isBeingDragged && isDragging
+ isDragging && isBeingDragged
? `translate3d(0, ${dragOffsetY}px, 0)`
: undefined,
}}
@@ -530,14 +531,14 @@ function ElementInner({
isExpanded: boolean;
baseTrackHeight: number;
expandedContent: React.ReactNode;
- onElementClick: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
- onElementMouseDown: (
- event: React.MouseEvent,
- element: TimelineElementType,
- ) => void;
+ onElementClick: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ }) => void;
+ onElementMouseDown: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ }) => void;
onResizeStart: (params: {
event: React.MouseEvent;
element: TimelineElementType;
@@ -578,8 +579,8 @@ function ElementInner({
type="button"
tabIndex={-1}
className="absolute inset-0 size-full flex flex-col"
- onClick={(event) => onElementClick(event, element)}
- onMouseDown={(event) => onElementMouseDown(event, element)}
+ onClick={(event) => onElementClick({ event, element })}
+ onMouseDown={(event) => onElementMouseDown({ event, element })}
>
<div
className={cn(
@@ -664,7 +665,7 @@ function KeyframeIndicators({
}: {
indicators: KeyframeIndicator[];
dragState: KeyframeDragState;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
elementLeft: number;
onKeyframeMouseDown: (params: {
event: React.MouseEvent;
@@ -674,13 +675,13 @@ function KeyframeIndicators({
event: React.MouseEvent;
keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
+ indicatorTime: MediaTime;
}) => void;
getVisualOffsetPx: (params: {
- indicatorTime: number;
+ indicatorTime: MediaTime;
indicatorOffsetPx: number;
isBeingDragged: boolean;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
elementLeft: number;
}) => number;
}) {
@@ -758,7 +759,7 @@ function ExpandedKeyframeLanes({
keyframes: ElementKeyframe[];
trackId: string;
elementId: string;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
zoomLevel: number;
elementLeft: number;
keyframeDragState: KeyframeDragState;
@@ -770,22 +771,20 @@ function ExpandedKeyframeLanes({
onLaneMouseDown: (event: React.MouseEvent) => void;
onLaneClick: (event: React.MouseEvent) => void;
selectionBox: {
- startPos: { x: number; y: number };
- currentPos: { x: number; y: number };
- isActive: boolean;
+ bounds: SelectionBoxBounds;
} | null;
isBoxSelecting: boolean;
onKeyframeClick: (params: {
event: React.MouseEvent;
keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
+ indicatorTime: MediaTime;
}) => void;
getVisualOffsetPx: (params: {
- indicatorTime: number;
+ indicatorTime: MediaTime;
indicatorOffsetPx: number;
isBeingDragged: boolean;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
elementLeft: number;
}) => number;
}) {
@@ -808,8 +807,7 @@ function ExpandedKeyframeLanes({
);
return (
- // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface
- // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (keyframe lanes); keyboard control over keyframes is via global timeline shortcuts, not per-element focus.
<div
ref={containerRef}
className="relative flex flex-col"
@@ -893,14 +891,7 @@ function ExpandedKeyframeLanes({
</div>
);
})}
- {selectionBox && (
- <SelectionBox
- startPos={selectionBox.startPos}
- currentPos={selectionBox.currentPos}
- containerRef={containerRef}
- isActive={selectionBox.isActive}
- />
- )}
+ {selectionBox && <SelectionBox bounds={selectionBox.bounds} />}
</div>
);
}
@@ -917,7 +908,9 @@ function TextElementContent({
}) {
return (
<div className="flex size-full items-center justify-start pl-2">
- <span className="truncate text-xs text-white">{element.content}</span>
+ <span className="truncate text-xs text-white">
+ {typeof element.params.content === "string" ? element.params.content : ""}
+ </span>
</div>
);
}
@@ -1073,7 +1066,7 @@ function EffectsButton({
editor.selection.setSelectedElements({
elements: [{ trackId: track.id, elementId: element.id }],
});
- setActiveTab(element.type, "effects");
+ setActiveTab({ elementType: element.type, tabId: "effects" });
};
return (
diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx
index 4dd3329f..6d77789e 100644
--- a/apps/web/src/timeline/components/timeline-playhead.tsx
+++ b/apps/web/src/timeline/components/timeline-playhead.tsx
@@ -1,13 +1,22 @@
"use client";
import { useRef } from "react";
+import { useContainerSize } from "@/hooks/use-container-size";
import {
getCenteredLineLeft,
TIMELINE_INDICATOR_LINE_WIDTH_PX,
timelineTimeToSnappedPixels,
} from "@/timeline";
+import { useScrollPosition } from "@/timeline/hooks/use-scroll-position";
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ addMediaTime,
+ maxMediaTime,
+ mediaTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { useEditor } from "@/editor/use-editor";
import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout";
import { TIMELINE_LAYERS } from "./layers";
@@ -45,11 +54,14 @@ export function TimelinePlayhead({
tracksScrollRef,
playheadRef,
});
+ const { height: timelineHeight } = useContainerSize({ containerRef: timelineRef });
+ const { height: tracksHeight } = useContainerSize({
+ containerRef: tracksScrollRef,
+ });
+ const { scrollLeft } = useScrollPosition({ scrollRef: tracksScrollRef });
const timelineContainerHeight =
- timelineRef.current?.clientHeight ??
- tracksScrollRef.current?.clientHeight ??
- 400;
+ timelineHeight || tracksHeight || 400;
const totalHeight = Math.max(
0,
timelineContainerHeight -
@@ -61,7 +73,6 @@ export function TimelinePlayhead({
time: currentTime,
zoomLevel,
});
- const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
const leftPosition =
getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft;
@@ -72,17 +83,24 @@ export function TimelinePlayhead({
event.preventDefault();
const fps = editor.project.getActive().settings.fps;
- const ticksPerFrame = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
+ const ticksPerFrame = mediaTime({
+ ticks: Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ ),
+ });
const direction = event.key === "ArrowRight" ? 1 : -1;
const now = editor.playback.getCurrentTime();
- const nextTime = Math.max(
- 0,
- Math.min(duration, now + direction * ticksPerFrame),
- );
+ const nextTime =
+ direction > 0
+ ? addMediaTime({ a: now, b: ticksPerFrame })
+ : subMediaTime({ a: now, b: ticksPerFrame });
- editor.playback.seek({ time: nextTime });
+ editor.playback.seek({
+ time: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: duration < nextTime ? duration : nextTime,
+ }),
+ });
};
return (
diff --git a/apps/web/src/timeline/components/timeline-ruler.tsx b/apps/web/src/timeline/components/timeline-ruler.tsx
index e2a379ee..ccacdae3 100644
--- a/apps/web/src/timeline/components/timeline-ruler.tsx
+++ b/apps/web/src/timeline/components/timeline-ruler.tsx
@@ -1,4 +1,4 @@
-import { type JSX, useLayoutEffect, useRef } from "react";
+import { type JSX } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
import { mediaTimeToSeconds } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/wasm";
@@ -51,20 +51,9 @@ export function TimelineRuler({
scrollRef: tracksScrollRef,
});
- /**
- * widens the virtualization buffer during zoom transitions.
- * useScrollPosition lags one frame behind the scroll adjustment
- * that useLayoutEffect applies after a zoom change.
- */
- const prevZoomRef = useRef(zoomLevel);
- const isZoomTransition = zoomLevel !== prevZoomRef.current;
- const bufferPx = isZoomTransition
- ? Math.max(200, (scrollLeft + viewportWidth) * 0.15)
- : 200;
-
- useLayoutEffect(() => {
- prevZoomRef.current = zoomLevel;
- }, [zoomLevel]);
+ // Keep extra buffer because zoom layout and scroll position can briefly
+ // settle on different frames.
+ const bufferPx = Math.max(200, (scrollLeft + viewportWidth) * 0.15);
const visibleStartTimeSeconds = Math.max(
0,
diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx
index 7608e6df..2603f511 100644
--- a/apps/web/src/timeline/components/timeline-track.tsx
+++ b/apps/web/src/timeline/components/timeline-track.tsx
@@ -62,7 +62,7 @@ export function TimelineTrackContent({
onTrackMouseDown?.(event);
}}
/>
- {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- spatial gesture surface; the wrapping <button> handles keyboard track selection, this <div> only forwards background clicks for box-select / deselect. */}
<div
className="relative h-full min-w-full"
style={{ zIndex: TIMELINE_LAYERS.trackContent }}
@@ -96,10 +96,10 @@ export function TimelineTrackContent({
onResizeStart={({ event, element, side }) =>
onResizeStart({ event, element, track, side })
}
- onElementMouseDown={(event, element) =>
+ onElementMouseDown={({ event, element }) =>
onElementMouseDown({ event, element, track })
}
- onElementClick={(event, element) =>
+ onElementClick={({ event, element }) =>
onElementClick({ event, element, track })
}
dragView={dragView}
diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts
index cd03300e..a419101f 100644
--- a/apps/web/src/timeline/controllers/drag-drop-controller.ts
+++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts
@@ -6,7 +6,7 @@ import {
toElementDurationTicks,
} from "@/timeline/creation";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { roundToFrame, type FrameRate } from "opencut-wasm";
+import type { FrameRate } from "opencut-wasm";
import {
buildTextElement,
buildGraphicElement,
@@ -30,6 +30,7 @@ import type {
import type { TimelineDragData } from "@/timeline/drag";
import type { MediaAsset } from "@/media/types";
import type { ProcessedMediaAsset } from "@/media/processing";
+import { roundFrameTime, type MediaTime } from "@/wasm";
// --- Config ---
@@ -41,7 +42,7 @@ export interface DragDropConfig {
getActiveProjectFps: () => FrameRate | null;
getActiveProjectId: () => string | null;
getSceneTracks: () => SceneTracks;
- getCurrentPlayheadTime: () => number;
+ getCurrentPlayheadTime: () => MediaTime;
getMediaAssets: () => MediaAsset[];
dragSource: TimelineDragSource;
addMediaAsset: (args: {
@@ -116,7 +117,7 @@ function getDurationForDrag({
}: {
dragData: TimelineDragData;
mediaAssets: MediaAsset[];
-}): number {
+}): MediaTime {
if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION;
const media = mediaAssets.find((asset) => asset.id === dragData.id);
return toElementDurationTicks({ seconds: media?.duration });
@@ -225,8 +226,7 @@ export class DragDropController {
const fps = this.config.getActiveProjectFps();
target.xPosition = fps
- ? (roundToFrame({ time: target.xPosition, rate: fps }) ??
- target.xPosition)
+ ? roundFrameTime({ time: target.xPosition, fps })
: target.xPosition;
this.setOver({ dropTarget: target, elementType });
@@ -326,7 +326,10 @@ export class DragDropController {
trackType: TrackType;
}): void {
if (target.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
+ const addTrackCmd = new AddTrackCommand({
+ type: trackType,
+ index: target.trackIndex,
+ });
this.config.executeCommand(
new BatchCommand([
addTrackCmd,
@@ -382,7 +385,10 @@ export class DragDropController {
dragData: Extract<TimelineDragData, { type: "text" }>;
}): void {
const element = buildTextElement({
- raw: { name: dragData.name ?? "", content: dragData.content ?? "" },
+ raw: {
+ name: dragData.name ?? "",
+ params: { content: dragData.content ?? "" },
+ },
startTime: target.xPosition,
});
this.insertAtTarget({ element, target, trackType: "text" });
diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts
index cc19daaa..5afc3a93 100644
--- a/apps/web/src/timeline/controllers/element-interaction-controller.ts
+++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts
@@ -7,9 +7,17 @@ import {
type MoveGroup,
} from "@/timeline/group-move";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ maxMediaTime,
+ type MediaTime,
+ mediaTime,
+ roundFrameTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
-import { roundToFrame, type FrameRate } from "opencut-wasm";
+import type { FrameRate } from "opencut-wasm";
import { computeDropTarget } from "@/timeline/components/drop-target";
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
@@ -52,7 +60,7 @@ export interface ElementSelectionApi {
}
export interface PlaybackReader {
- getCurrentTime: () => number;
+ getCurrentTime: () => MediaTime;
}
export interface TimelineOps {
@@ -86,8 +94,8 @@ interface MousedownSnapshot {
readonly origin: Point;
readonly elementId: string;
readonly trackId: string;
- readonly startElementTime: number;
- readonly clickOffsetTime: number;
+ readonly startElementTime: MediaTime;
+ readonly clickOffsetTime: MediaTime;
readonly selectedElements: readonly ElementRef[];
}
@@ -100,7 +108,7 @@ interface DragProgress {
// indicator, drop-line, commit path) see the same entity every frame
// instead of a churning UUID.
reservedNewTrackIds: readonly string[];
- currentTime: number;
+ currentTime: MediaTime;
currentMouseX: number;
currentMouseY: number;
groupMoveResult: GroupMoveResult | null;
@@ -116,20 +124,27 @@ const IDLE_VIEW: ElementDragView = { kind: "idle" };
// --- Pure helpers ---
-function pixelToClickOffsetTime(
- clientX: number,
- elementRect: DOMRect,
- zoomLevel: number,
-): number {
+function pixelToClickOffsetTime({
+ clientX,
+ elementRect,
+ zoomLevel,
+}: {
+ clientX: number;
+ elementRect: DOMRect;
+ zoomLevel: number;
+}): MediaTime {
const clickOffsetX = clientX - elementRect.left;
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
- return Math.round(seconds * TICKS_PER_SECOND);
+ return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) });
}
-function verticalDirection(
- startMouseY: number,
- currentMouseY: number,
-): "up" | "down" | null {
+function verticalDirection({
+ startMouseY,
+ currentMouseY,
+}: {
+ startMouseY: number;
+ currentMouseY: number;
+}): "up" | "down" | null {
if (currentMouseY < startMouseY) return "up";
if (currentMouseY > startMouseY) return "down";
return null;
@@ -139,7 +154,13 @@ function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] {
return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio];
}
-function movedPastDragThreshold(current: Point, origin: Point): boolean {
+function movedPastDragThreshold({
+ current,
+ origin,
+}: {
+ current: Point;
+ origin: Point;
+}): boolean {
return (
Math.abs(current.x - origin.x) > TIMELINE_DRAG_THRESHOLD_PX ||
Math.abs(current.y - origin.y) > TIMELINE_DRAG_THRESHOLD_PX
@@ -156,17 +177,20 @@ function frameSnappedMouseTime({
clientX: number;
scrollContainer: HTMLDivElement;
zoomLevel: number;
- clickOffsetTime: number;
+ clickOffsetTime: MediaTime;
fps: FrameRate;
-}): number {
+}): MediaTime {
const mouseTime = getMouseTimeFromClientX({
clientX,
containerRect: scrollContainer.getBoundingClientRect(),
zoomLevel,
scrollLeft: scrollContainer.scrollLeft,
});
- const adjusted = Math.max(0, mouseTime - clickOffsetTime);
- return roundToFrame({ time: adjusted, rate: fps }) ?? adjusted;
+ const adjusted = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({ a: mouseTime, b: clickOffsetTime }),
+ });
+ return roundFrameTime({ time: adjusted, fps });
}
function resolveDropTarget({
@@ -187,7 +211,7 @@ function resolveDropTarget({
tracks: SceneTracks;
viewport: ViewportAdapter;
zoomLevel: number;
- snappedTime: number;
+ snappedTime: MediaTime;
verticalDragDirection: "up" | "down" | null;
}): DropTarget | null {
const containerRect = viewport
@@ -231,7 +255,7 @@ function resolveGroupMoveForDrop({
}: {
group: MoveGroup;
tracks: SceneTracks;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
dropTarget: DropTarget;
reservedNewTrackIds: readonly string[];
}): GroupMoveResult | null {
@@ -286,7 +310,7 @@ export class ElementInteractionController {
get view(): ElementDragView {
if (this.session.kind !== "dragging") return IDLE_VIEW;
const { mousedown, drag } = this.session;
- const memberTimeOffsets = new Map<string, number>();
+ const memberTimeOffsets = new Map<string, MediaTime>();
for (const member of drag.moveGroup.members) {
memberTimeOffsets.set(member.elementId, member.timeOffset);
}
@@ -363,11 +387,11 @@ export class ElementInteractionController {
elementId: element.id,
trackId: track.id,
startElementTime: element.startTime,
- clickOffsetTime: pixelToClickOffsetTime(
- event.clientX,
- event.currentTarget.getBoundingClientRect(),
- this.deps.viewport.getZoomLevel(),
- ),
+ clickOffsetTime: pixelToClickOffsetTime({
+ clientX: event.clientX,
+ elementRect: event.currentTarget.getBoundingClientRect(),
+ zoomLevel: this.deps.viewport.getZoomLevel(),
+ }),
selectedElements,
},
};
@@ -426,10 +450,13 @@ export class ElementInteractionController {
this.notify();
}
- private snapResult(
- frameSnappedTime: number,
- group: MoveGroup,
- ): { snappedTime: number; snapPoint: SnapPoint | null } {
+ private snapResult({
+ frameSnappedTime,
+ group,
+ }: {
+ frameSnappedTime: MediaTime;
+ group: MoveGroup;
+ }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } {
const { snap, input, scene, viewport, playback } = this.deps;
if (!snap.isEnabled() || input.isShiftHeld()) {
@@ -461,7 +488,7 @@ export class ElementInteractionController {
clientY: number;
mousedown: MousedownSnapshot;
drag: DragProgress;
- snappedTime: number;
+ snappedTime: MediaTime;
}): void {
const { scene, viewport } = this.deps;
const tracks = scene.getTracks();
@@ -476,7 +503,10 @@ export class ElementInteractionController {
viewport,
zoomLevel,
snappedTime,
- verticalDragDirection: verticalDirection(mousedown.origin.y, clientY),
+ verticalDragDirection: verticalDirection({
+ startMouseY: mousedown.origin.y,
+ currentMouseY: clientY,
+ }),
});
const nextGroupMoveResult = anchorDropTarget
@@ -532,7 +562,12 @@ export class ElementInteractionController {
clientY: number;
scrollContainer: HTMLDivElement;
}): void {
- if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) {
+ if (
+ !movedPastDragThreshold({
+ current: { x: clientX, y: clientY },
+ origin: mousedown.origin,
+ })
+ ) {
return;
}
@@ -557,10 +592,10 @@ export class ElementInteractionController {
clickOffsetTime: mousedown.clickOffsetTime,
fps,
});
- const { snappedTime, snapPoint } = this.snapResult(
+ const { snappedTime, snapPoint } = this.snapResult({
frameSnappedTime,
- moveGroup,
- );
+ group: moveGroup,
+ });
// Ensure the anchor is selected before we render the drag — covers the
// case where the selection store hasn't committed the mousedown-time
@@ -621,10 +656,10 @@ export class ElementInteractionController {
clickOffsetTime: mousedown.clickOffsetTime,
fps,
});
- const { snappedTime, snapPoint } = this.snapResult(
+ const { snappedTime, snapPoint } = this.snapResult({
frameSnappedTime,
- drag.moveGroup,
- );
+ group: drag.moveGroup,
+ });
drag.currentTime = snappedTime;
drag.currentMouseX = clientX;
@@ -655,7 +690,12 @@ export class ElementInteractionController {
// If the drag returned within the click threshold of its origin, treat
// this as a cancel rather than a commit — the user dragged then put the
// element back.
- if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) {
+ if (
+ !movedPastDragThreshold({
+ current: { x: clientX, y: clientY },
+ origin: mousedown.origin,
+ })
+ ) {
this.lastGestureWasDrag = false;
this.finishSession();
return;
diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
index 51c60dee..e3a9cd96 100644
--- a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
+++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
@@ -1,7 +1,16 @@
import type { MouseEvent as ReactMouseEvent } from "react";
-import { roundToFrame, snappedSeekTime, type FrameRate } from "opencut-wasm";
+import type { FrameRate } from "opencut-wasm";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ addMediaTime,
+ clampMediaTime,
+ type MediaTime,
+ mediaTime,
+ roundFrameTicks,
+ snapSeekMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
import { timelineTimeToSnappedPixels } from "@/timeline";
import { getKeyframeById } from "@/animation";
@@ -48,7 +57,7 @@ export interface KeyframeDragConfig {
zoomLevel: number;
getFps: () => FrameRate | null;
element: TimelineElement;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
selectedKeyframes: SelectedKeyframeRef[];
isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean;
setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void;
@@ -62,8 +71,8 @@ export interface KeyframeDragConfig {
isAdditive: boolean;
}) => void;
executeCommand: (command: Command) => void;
- seek: (args: { time: number }) => void;
- getTotalDuration: () => number;
+ seek: (args: { time: MediaTime }) => void;
+ getTotalDuration: () => MediaTime;
}
export interface KeyframeDragConfigRef {
@@ -161,7 +170,7 @@ export class KeyframeDragController {
event: ReactMouseEvent;
keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
+ indicatorTime: MediaTime;
}): void {
event.stopPropagation();
@@ -174,14 +183,18 @@ export class KeyframeDragController {
const { displayedStartTime, getFps, getTotalDuration, seek } = this.config;
const fps = getFps();
+ const absoluteIndicatorTime = addMediaTime({
+ a: displayedStartTime,
+ b: indicatorTime,
+ });
const seekTime =
fps != null
- ? (snappedSeekTime({
- time: displayedStartTime + indicatorTime,
+ ? snapSeekMediaTime({
+ time: absoluteIndicatorTime,
duration: getTotalDuration(),
- rate: fps,
- }) ?? displayedStartTime + indicatorTime)
- : displayedStartTime + indicatorTime;
+ fps,
+ })
+ : absoluteIndicatorTime;
seek({ time: seekTime });
if (event.shiftKey) {
@@ -206,24 +219,23 @@ export class KeyframeDragController {
displayedStartTime,
elementLeft,
}: {
- indicatorTime: number;
+ indicatorTime: MediaTime;
indicatorOffsetPx: number;
isBeingDragged: boolean;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
elementLeft: number;
}): number {
if (!isBeingDragged || this.session.kind !== "active")
return indicatorOffsetPx;
- const clampedTime = Math.max(
- 0,
- Math.min(
- this.config.element.duration,
- indicatorTime + this.session.deltaTicks,
- ),
- );
+ const deltaTime = mediaTime({ ticks: this.session.deltaTicks });
+ const clampedTime = clampMediaTime({
+ time: addMediaTime({ a: indicatorTime, b: deltaTime }),
+ min: ZERO_MEDIA_TIME,
+ max: this.config.element.duration,
+ });
return (
timelineTimeToSnappedPixels({
- time: displayedStartTime + clampedTime,
+ time: addMediaTime({ a: displayedStartTime, b: clampedTime }),
zoomLevel: this.config.zoomLevel,
}) - elementLeft
);
@@ -270,10 +282,14 @@ export class KeyframeDragController {
elementId: ref.elementId,
propertyPath: ref.propertyPath,
keyframeId: ref.keyframeId,
- nextTime: Math.max(
- 0,
- Math.min(element.duration, keyframe.time + deltaTicks),
- ),
+ nextTime: clampMediaTime({
+ time: addMediaTime({
+ a: keyframe.time,
+ b: mediaTime({ ticks: deltaTicks }),
+ }),
+ min: ZERO_MEDIA_TIME,
+ max: element.duration,
+ }),
}),
];
});
@@ -313,8 +329,7 @@ export class KeyframeDragController {
((clientX - this.session.startMouseX) / pixelsPerSecond) *
TICKS_PER_SECOND,
);
- this.session.deltaTicks =
- roundToFrame({ time: rawDeltaTicks, rate: fps }) ?? rawDeltaTicks;
+ this.session.deltaTicks = roundFrameTicks({ ticks: rawDeltaTicks, fps });
this.notify();
}
diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts
index d2b7d82f..e914ff21 100644
--- a/apps/web/src/timeline/controllers/playhead-controller.ts
+++ b/apps/web/src/timeline/controllers/playhead-controller.ts
@@ -1,6 +1,11 @@
import type { MouseEvent as ReactMouseEvent } from "react";
-import { snappedSeekTime, type FrameRate } from "opencut-wasm";
-import { TICKS_PER_SECOND } from "@/wasm";
+import type { FrameRate } from "opencut-wasm";
+import {
+ mediaTime,
+ snapSeekMediaTime,
+ TICKS_PER_SECOND,
+ type MediaTime,
+} from "@/wasm";
import {
buildTimelineSnapPoints,
getTimelineSnapThresholdInTicks,
@@ -26,7 +31,7 @@ interface ScrubSession {
/** True once the mouse has moved during a ruler drag. */
hasMoved: boolean;
/** Most recent frame-snapped time set by scrub(). */
- currentTime: number | null;
+ currentTime: MediaTime | null;
}
type Session = { kind: "idle" } | ScrubSession;
@@ -35,7 +40,7 @@ type Session = { kind: "idle" } | ScrubSession;
export interface PlayheadConfig {
zoomLevel: number;
- duration: number;
+ duration: MediaTime;
getActiveProjectFps: () => FrameRate | null;
isShiftHeld: () => boolean;
getIsPlaying: () => boolean;
@@ -45,12 +50,12 @@ export interface PlayheadConfig {
getPlayheadEl: () => HTMLDivElement | null;
getSceneTracks: () => SceneTracks;
getSceneBookmarks: () => Bookmark[];
- seek: (time: number) => void;
+ seek: (time: MediaTime) => void;
setScrubbing: (isScrubbing: boolean) => void;
setTimelineViewState: (viewState: {
zoomLevel: number;
scrollLeft: number;
- playheadTime: number;
+ playheadTime: MediaTime;
}) => void;
}
@@ -69,8 +74,8 @@ function pixelToTime({
clientX: number;
rulerEl: HTMLDivElement;
zoomLevel: number;
- duration: number;
-}): number {
+ duration: MediaTime;
+}): MediaTime {
const rulerRect = rulerEl.getBoundingClientRect();
const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
const clampedX = Math.max(
@@ -84,7 +89,7 @@ function pixelToTime({
clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
),
);
- return Math.round(seconds * TICKS_PER_SECOND);
+ return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) });
}
// --- Controller ---
@@ -159,7 +164,7 @@ export class PlayheadController {
* Called on scroll and playback events to avoid React re-renders
* during animation frame updates.
*/
- updatePlayheadLeft(time: number): void {
+ updatePlayheadLeft(time: MediaTime): void {
const playheadEl = this.config.getPlayheadEl();
if (!playheadEl) return;
@@ -175,7 +180,7 @@ export class PlayheadController {
* Updates the playhead position and auto-scrolls to keep the playhead
* visible during playback.
*/
- handlePlaybackUpdate(time: number): void {
+ handlePlaybackUpdate(time: MediaTime): void {
this.updatePlayheadLeft(time);
// Auto-scroll only during playback, not while scrubbing.
@@ -244,8 +249,7 @@ export class PlayheadController {
zoomLevel,
duration,
});
- const frameTime =
- snappedSeekTime({ time: rawTime, duration, rate: fps }) ?? rawTime;
+ const frameTime = snapSeekMediaTime({ time: rawTime, duration, fps });
const time = (() => {
if (!isElementSnappingEnabled || this.config.isShiftHeld())
diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts
index cdf502a1..a1098915 100644
--- a/apps/web/src/timeline/controllers/resize-controller.ts
+++ b/apps/web/src/timeline/controllers/resize-controller.ts
@@ -1,6 +1,14 @@
import type { MouseEvent as ReactMouseEvent } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ addMediaTime,
+ maxMediaTime,
+ type MediaTime,
+ mediaTime,
+ minMediaTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+} from "@/wasm";
import {
computeGroupResize,
type GroupResizeMember,
@@ -46,7 +54,7 @@ export interface ResizeConfig {
snappingEnabled: boolean;
isShiftHeld: () => boolean;
getSceneTracks: () => SceneTracks;
- getCurrentPlayheadTime: () => number;
+ getCurrentPlayheadTime: () => MediaTime;
getActiveProjectFps: () => FrameRate | null;
selectedElements: ElementRef[];
discardPreview: () => void;
@@ -87,14 +95,32 @@ export function buildResizeMembers({
(el) => !selectedElementIds.has(el.id),
);
const leftNeighborBound = otherElements
- .filter((el) => el.startTime + el.duration <= element.startTime)
- .reduce(
- (bound, el) => Math.max(bound, el.startTime + el.duration),
- -Infinity,
- );
+ .filter(
+ (el) =>
+ addMediaTime({ a: el.startTime, b: el.duration }) <=
+ element.startTime,
+ )
+ .reduce<MediaTime | null>((bound, el) => {
+ const elementEnd = addMediaTime({
+ a: el.startTime,
+ b: el.duration,
+ });
+ return bound === null
+ ? elementEnd
+ : maxMediaTime({ a: bound, b: elementEnd });
+ }, null);
const rightNeighborBound = otherElements
- .filter((el) => el.startTime >= element.startTime + element.duration)
- .reduce((bound, el) => Math.min(bound, el.startTime), Infinity);
+ .filter(
+ (el) =>
+ el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }),
+ )
+ .reduce<MediaTime | null>(
+ (bound, el) =>
+ bound === null
+ ? el.startTime
+ : minMediaTime({ a: bound, b: el.startTime }),
+ null,
+ );
return [
{
@@ -237,12 +263,18 @@ export class ResizeController {
this.notify();
}
- private snappedDelta(session: ResizeSession, rawDeltaTicks: number): number {
+ private snappedDelta({
+ session,
+ rawDeltaTime,
+ }: {
+ session: ResizeSession;
+ rawDeltaTime: MediaTime;
+ }): MediaTime {
const { snappingEnabled, isShiftHeld, zoomLevel } = this.config;
if (!snappingEnabled || isShiftHeld()) {
this.config.onSnapPointChange?.(null);
- return rawDeltaTicks;
+ return rawDeltaTime;
}
const tracks = this.config.getSceneTracks();
@@ -264,15 +296,15 @@ export class ResizeController {
let closestSnapPoint: SnapPoint | null = null;
let closestSnapDistance = Infinity;
- let deltaTicks = rawDeltaTicks;
+ let deltaTime = rawDeltaTime;
for (const member of session.members) {
const baseEdgeTime =
session.side === "left"
? member.startTime
- : member.startTime + member.duration;
+ : addMediaTime({ a: member.startTime, b: member.duration });
const snapResult = resolveTimelineSnap({
- targetTime: baseEdgeTime + rawDeltaTicks,
+ targetTime: addMediaTime({ a: baseEdgeTime, b: rawDeltaTime }),
snapPoints,
maxSnapDistance,
});
@@ -282,28 +314,30 @@ export class ResizeController {
) {
closestSnapDistance = snapResult.snapDistance;
closestSnapPoint = snapResult.snapPoint;
- deltaTicks = snapResult.snappedTime - baseEdgeTime;
+ deltaTime = subMediaTime({ a: snapResult.snappedTime, b: baseEdgeTime });
}
}
this.config.onSnapPointChange?.(closestSnapPoint);
- return deltaTicks;
+ return deltaTime;
}
private handleMouseMove({ clientX }: MouseEvent): void {
if (this.session.kind !== "active") return;
const session = this.session;
- const rawDeltaTicks = Math.round(
- ((clientX - session.startX) /
- (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
- TICKS_PER_SECOND,
- );
- const deltaTicks = this.snappedDelta(session, rawDeltaTicks);
+ const rawDeltaTime = mediaTime({
+ ticks: Math.round(
+ ((clientX - session.startX) /
+ (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
+ TICKS_PER_SECOND,
+ ),
+ });
+ const deltaTime = this.snappedDelta({ session, rawDeltaTime });
const result = computeGroupResize({
members: session.members,
side: session.side,
- deltaTicks,
+ deltaTime,
fps: session.fps,
});
diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts
index f32c855e..67dc6e96 100644
--- a/apps/web/src/timeline/controllers/seek-controller.ts
+++ b/apps/web/src/timeline/controllers/seek-controller.ts
@@ -1,7 +1,7 @@
import type { MouseEvent as ReactMouseEvent } from "react";
-import { snappedSeekTime, type FrameRate } from "opencut-wasm";
+import type { FrameRate } from "opencut-wasm";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
+import { mediaTime, snapSeekMediaTime, TICKS_PER_SECOND, type MediaTime } from "@/wasm";
type SeekSource = "ruler" | "tracks";
@@ -17,7 +17,7 @@ type Session = { kind: "idle" } | PendingSeekSession;
export interface SeekConfig {
zoomLevel: number;
- duration: number;
+ duration: MediaTime;
isSelecting: boolean;
getPlayheadEl: () => HTMLDivElement | null;
getTrackLabelsEl: () => HTMLDivElement | null;
@@ -25,11 +25,11 @@ export interface SeekConfig {
getTracksScrollEl: () => HTMLDivElement | null;
getActiveProjectFps: () => FrameRate | null;
clearSelectedElements: () => void;
- seek: (time: number) => void;
+ seek: (time: MediaTime) => void;
setTimelineViewState: (viewState: {
zoomLevel: number;
scrollLeft: number;
- playheadTime: number;
+ playheadTime: MediaTime;
}) => void;
}
@@ -46,8 +46,8 @@ function pixelToTime({
clientX: number;
scrollContainer: HTMLDivElement;
zoomLevel: number;
- duration: number;
-}): number {
+ duration: MediaTime;
+}): MediaTime {
const rect = scrollContainer.getBoundingClientRect();
const mouseX = clientX - rect.left;
const scrollLeft = scrollContainer.scrollLeft;
@@ -60,7 +60,7 @@ function pixelToTime({
),
);
- return Math.round(rawTimeSeconds * TICKS_PER_SECOND);
+ return mediaTime({ ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND) });
}
function isClickGesture({
@@ -193,11 +193,11 @@ export class SeekController {
const fps = this.config.getActiveProjectFps();
const time =
fps != null
- ? (snappedSeekTime({
+ ? snapSeekMediaTime({
time: rawTime,
duration: this.config.duration,
- rate: fps,
- }) ?? rawTime)
+ fps,
+ })
: rawTime;
this.config.seek(time);
diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts
index 8e0af4fe..91147354 100644
--- a/apps/web/src/timeline/controllers/zoom-controller.ts
+++ b/apps/web/src/timeline/controllers/zoom-controller.ts
@@ -3,6 +3,7 @@ import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/i
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
import { TIMELINE_ZOOM_MAX } from "@/timeline/scale";
import { zoomToSlider } from "@/timeline/zoom-utils";
+import type { MediaTime } from "@/wasm";
type ZoomUpdater = number | ((prev: number) => number);
@@ -11,12 +12,12 @@ export interface ZoomConfig {
getContainerEl: () => HTMLDivElement | null;
getTracksScrollEl: () => HTMLDivElement | null;
getRulerScrollEl: () => HTMLDivElement | null;
- getCurrentPlayheadTime: () => number;
- seek: (time: number) => void;
+ getCurrentPlayheadTime: () => MediaTime;
+ seek: (time: MediaTime) => void;
setTimelineViewState: (viewState: {
zoomLevel: number;
scrollLeft: number;
- playheadTime: number;
+ playheadTime: MediaTime;
}) => void;
}
@@ -24,7 +25,13 @@ export interface ZoomConfigRef {
readonly current: ZoomConfig;
}
-function clampZoom(zoomLevel: number, minZoom: number): number {
+function clampZoom({
+ zoomLevel,
+ minZoom,
+}: {
+ zoomLevel: number;
+ minZoom: number;
+}): number {
return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel));
}
@@ -48,7 +55,7 @@ export class ZoomController {
const minZoom = this.config.minZoom;
this.zoomLevelValue =
deps.initialZoom !== undefined
- ? clampZoom(deps.initialZoom, minZoom)
+ ? clampZoom({ zoomLevel: deps.initialZoom, minZoom })
: minZoom;
this.previousZoom = this.zoomLevelValue;
this.hasInitialized = deps.initialZoom !== undefined;
@@ -88,7 +95,10 @@ export class ZoomController {
typeof zoomLevelOrUpdater === "function"
? zoomLevelOrUpdater(this.zoomLevelValue)
: zoomLevelOrUpdater;
- const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom);
+ const nextZoom = clampZoom({
+ zoomLevel: nextZoomRaw,
+ minZoom: this.config.minZoom,
+ });
if (nextZoom === this.zoomLevelValue) return;
this.zoomLevelValue = nextZoom;
@@ -114,10 +124,16 @@ export class ZoomController {
}
}
- reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void {
+ reconcileInitialAndMinZoom({
+ minZoom,
+ initialZoom,
+ }: {
+ minZoom: number;
+ initialZoom?: number;
+ }): void {
if (initialZoom !== undefined && !this.hasInitialized) {
this.hasInitialized = true;
- this.setZoomLevel(clampZoom(initialZoom, minZoom));
+ this.setZoomLevel(clampZoom({ zoomLevel: initialZoom, minZoom }));
return;
}
@@ -249,7 +265,7 @@ export class ZoomController {
return () => observer.disconnect();
}
- restoreInitialPlayheadIfNeeded(initialPlayheadTime?: number): void {
+ restoreInitialPlayheadIfNeeded(initialPlayheadTime?: MediaTime): void {
if (initialPlayheadTime === undefined) return;
if (this.hasRestoredPlayhead) return;
diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts
index 2dec3186..0f28af56 100644
--- a/apps/web/src/timeline/creation.ts
+++ b/apps/web/src/timeline/creation.ts
@@ -1,20 +1,17 @@
-import { TICKS_PER_SECOND } from "@/wasm/ticks";
+import { mediaTime, mediaTimeFromSeconds, TICKS_PER_SECOND } from "@/wasm";
-export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND;
+export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({
+ ticks: 5 * TICKS_PER_SECOND,
+});
-/**
- * Resolves the duration in ticks that a newly created timeline element
- * should have, given an optional source duration in seconds.
- *
- * Falls back to {@link DEFAULT_NEW_ELEMENT_DURATION} when the source has
- * no known duration (e.g. stickers, graphics, text, or media whose
- * probe failed).
- */
export function toElementDurationTicks({
seconds,
}: {
seconds: number | null | undefined;
-}): number {
- if (seconds == null) return DEFAULT_NEW_ELEMENT_DURATION;
- return Math.round(seconds * TICKS_PER_SECOND);
+}) {
+ if (seconds == null) {
+ return DEFAULT_NEW_ELEMENT_DURATION;
+ }
+
+ return mediaTimeFromSeconds({ seconds });
}
diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts
index 06639e01..d32fb649 100644
--- a/apps/web/src/timeline/defaults.ts
+++ b/apps/web/src/timeline/defaults.ts
@@ -1,6 +1,7 @@
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import type { TTimelineViewState } from "@/project/types";
import type { BlendMode, Transform } from "@/rendering";
+import { ZERO_MEDIA_TIME } from "@/wasm";
import type { TextElement } from "./types";
const defaultTransform: Transform = {
@@ -30,32 +31,42 @@ const defaultTextBackground = {
const defaultTextElement: Omit<TextElement, "id"> = {
type: "text",
name: "Text",
- content: "Default text",
- fontSize: 15,
- fontFamily: "Arial",
- color: "#ffffff",
- background: { ...defaultTextBackground },
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- letterSpacing: defaultTextLetterSpacing,
- lineHeight: defaultTextLineHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- ...defaultTransform,
- position: { ...defaultTransform.position },
+ startTime: ZERO_MEDIA_TIME,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ params: {
+ content: "Default text",
+ fontSize: 15,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: defaultTextLetterSpacing,
+ lineHeight: defaultTextLineHeight,
+ "background.enabled": defaultTextBackground.enabled,
+ "background.color": defaultTextBackground.color,
+ "background.cornerRadius": defaultTextBackground.cornerRadius,
+ "background.paddingX": defaultTextBackground.paddingX,
+ "background.paddingY": defaultTextBackground.paddingY,
+ "background.offsetX": defaultTextBackground.offsetX,
+ "background.offsetY": defaultTextBackground.offsetY,
+ "transform.positionX": defaultTransform.position.x,
+ "transform.positionY": defaultTransform.position.y,
+ "transform.scaleX": defaultTransform.scaleX,
+ "transform.scaleY": defaultTransform.scaleY,
+ "transform.rotate": defaultTransform.rotate,
+ opacity: defaultOpacity,
+ blendMode: defaultBlendMode,
},
- opacity: defaultOpacity,
};
const defaultTimelineViewState: TTimelineViewState = {
zoomLevel: 1,
scrollLeft: 0,
- playheadTime: 0,
+ playheadTime: ZERO_MEDIA_TIME,
};
export const DEFAULTS = {
diff --git a/apps/web/src/timeline/drag-utils.ts b/apps/web/src/timeline/drag-utils.ts
index a731d7a0..ad8d8001 100644
--- a/apps/web/src/timeline/drag-utils.ts
+++ b/apps/web/src/timeline/drag-utils.ts
@@ -1,5 +1,5 @@
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
+import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
export function getMouseTimeFromClientX({
clientX,
@@ -11,11 +11,13 @@ export function getMouseTimeFromClientX({
containerRect: DOMRect;
zoomLevel: number;
scrollLeft: number;
-}): number {
+}): MediaTime {
const mouseX = clientX - containerRect.left + scrollLeft;
const seconds = Math.max(
0,
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
);
- return Math.round(seconds * TICKS_PER_SECOND);
+ return mediaTime({
+ ticks: Math.round(seconds * TICKS_PER_SECOND),
+ });
}
diff --git a/apps/web/src/timeline/element-snap-source.ts b/apps/web/src/timeline/element-snap-source.ts
index ec1e5f65..26aa86f6 100644
--- a/apps/web/src/timeline/element-snap-source.ts
+++ b/apps/web/src/timeline/element-snap-source.ts
@@ -1,5 +1,6 @@
import type { SceneTracks } from "@/timeline";
import type { SnapPoint } from "@/timeline/snapping";
+import { addMediaTime } from "@/wasm";
export function getElementEdgeSnapPoints({
tracks,
@@ -25,7 +26,7 @@ export function getElementEdgeSnapPoints({
trackId: track.id,
},
{
- time: element.startTime + element.duration,
+ time: addMediaTime({ a: element.startTime, b: element.duration }),
type: "element-end",
elementId: element.id,
trackId: track.id,
diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts
index 676bc823..10c759d9 100644
--- a/apps/web/src/timeline/element-utils.ts
+++ b/apps/web/src/timeline/element-utils.ts
@@ -11,7 +11,6 @@ import {
type CreateStickerElement,
type CreateUploadAudioElement,
type CreateLibraryAudioElement,
- type TextBackground,
type TextElement,
type SceneTracks,
type TimelineElement,
@@ -28,7 +27,12 @@ import type { MediaType } from "@/media/types";
import { buildDefaultEffectInstance } from "@/effects";
import { buildDefaultGraphicInstance } from "@/graphics";
import type { ParamValues } from "@/params";
+import {
+ buildDefaultParamValues,
+ getBuiltInElementParams,
+} from "@/params/registry";
import { capitalizeFirstLetter } from "@/utils/string";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export function canElementHaveAudio(
element: TimelineElement,
@@ -86,20 +90,12 @@ export function requiresMediaId({
);
}
-function buildTextBackground(
- raw: Partial<TextBackground> | undefined,
-): TextBackground {
- const color = raw?.color ?? DEFAULTS.text.element.background.color;
- const enabled = raw?.enabled ?? color !== "transparent";
- return {
- enabled,
- color,
- cornerRadius: raw?.cornerRadius,
- paddingX: raw?.paddingX,
- paddingY: raw?.paddingY,
- offsetX: raw?.offsetX,
- offsetY: raw?.offsetY,
- };
+function buildDefaultElementParams({
+ type,
+}: {
+ type: TimelineElement["type"];
+}): ParamValues {
+ return buildDefaultParamValues(getBuiltInElementParams({ type }));
}
export function buildTextElement({
@@ -107,31 +103,21 @@ export function buildTextElement({
startTime,
}: {
raw: Partial<Omit<TextElement, "type" | "id">>;
- startTime: number;
+ startTime: MediaTime;
}): CreateTimelineElement {
const t = raw as Partial<TextElement>;
return {
type: "text",
name: t.name ?? DEFAULTS.text.element.name,
- content: t.content ?? DEFAULTS.text.element.content,
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
startTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
- fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
- color: t.color ?? DEFAULTS.text.element.color,
- background: buildTextBackground(t.background),
- textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
- fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
- fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
- textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
- letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
- lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
- transform: t.transform ?? DEFAULTS.text.element.transform,
- opacity: t.opacity ?? DEFAULTS.text.element.opacity,
- blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ params: {
+ ...buildDefaultElementParams({ type: "text" }),
+ ...(t.params ?? {}),
+ },
};
}
@@ -141,8 +127,8 @@ export function buildEffectElement({
duration,
}: {
effectType: string;
- startTime: number;
- duration?: number;
+ startTime: MediaTime;
+ duration?: MediaTime;
}): CreateEffectElement {
const instance = buildDefaultEffectInstance({ effectType });
return {
@@ -152,8 +138,8 @@ export function buildEffectElement({
params: instance.params,
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
};
}
@@ -166,7 +152,7 @@ export function buildStickerElement({
}: {
stickerId: string;
name?: string;
- startTime: number;
+ startTime: MediaTime;
intrinsicWidth?: number;
intrinsicHeight?: number;
}): CreateStickerElement {
@@ -180,14 +166,9 @@ export function buildStickerElement({
intrinsicHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ params: buildDefaultElementParams({ type: "sticker" }),
};
}
@@ -199,7 +180,7 @@ export function buildGraphicElement({
}: {
definitionId: string;
name?: string;
- startTime: number;
+ startTime: MediaTime;
params?: Partial<ParamValues>;
}): CreateGraphicElement {
const instance = buildDefaultGraphicInstance({ definitionId });
@@ -207,20 +188,36 @@ export function buildGraphicElement({
type: "graphic",
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
definitionId: instance.definitionId,
- params: { ...instance.params, ...(params ?? {}) } as ParamValues,
+ params: mergeParamValues({
+ base: {
+ ...buildDefaultElementParams({ type: "graphic" }),
+ ...instance.params,
+ },
+ overrides: params,
+ }),
duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
};
}
+function mergeParamValues({
+ base,
+ overrides,
+}: {
+ base: ParamValues;
+ overrides?: Partial<ParamValues>;
+}): ParamValues {
+ const result: ParamValues = { ...base };
+ for (const [key, value] of Object.entries(overrides ?? {})) {
+ if (value !== undefined) {
+ result[key] = value;
+ }
+ }
+ return result;
+}
+
function buildVideoElement({
mediaId,
name,
@@ -229,8 +226,8 @@ function buildVideoElement({
}: {
mediaId: string;
name: string;
- duration: number;
- startTime: number;
+ duration: MediaTime;
+ startTime: MediaTime;
}): CreateVideoElement {
return {
type: "video",
@@ -238,19 +235,12 @@ function buildVideoElement({
name,
duration,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration,
- muted: false,
isSourceAudioEnabled: true,
hidden: false,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
- volume: DEFAULTS.element.volume,
+ params: buildDefaultElementParams({ type: "video" }),
};
}
@@ -262,8 +252,8 @@ function buildImageElement({
}: {
mediaId: string;
name: string;
- duration: number;
- startTime: number;
+ duration: MediaTime;
+ startTime: MediaTime;
}): CreateImageElement {
return {
type: "image",
@@ -271,15 +261,10 @@ function buildImageElement({
name,
duration,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
hidden: false,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
+ params: buildDefaultElementParams({ type: "image" }),
};
}
@@ -292,8 +277,8 @@ function buildUploadAudioElement({
}: {
mediaId: string;
name: string;
- duration: number;
- startTime: number;
+ duration: MediaTime;
+ startTime: MediaTime;
buffer?: AudioBuffer;
}): CreateUploadAudioElement {
const element: CreateUploadAudioElement = {
@@ -303,11 +288,10 @@ function buildUploadAudioElement({
name,
duration,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration,
- volume: DEFAULTS.element.volume,
- muted: false,
+ params: buildDefaultElementParams({ type: "audio" }),
};
if (buffer) {
element.buffer = buffer;
@@ -326,8 +310,8 @@ export function buildElementFromMedia({
mediaId: string;
mediaType: MediaType;
name: string;
- duration: number;
- startTime: number;
+ duration: MediaTime;
+ startTime: MediaTime;
buffer?: AudioBuffer;
}): CreateTimelineElement {
switch (mediaType) {
@@ -355,8 +339,8 @@ export function buildLibraryAudioElement({
}: {
sourceUrl: string;
name: string;
- duration: number;
- startTime: number;
+ duration: MediaTime;
+ startTime: MediaTime;
buffer?: AudioBuffer;
}): CreateLibraryAudioElement {
const element: CreateLibraryAudioElement = {
@@ -366,11 +350,10 @@ export function buildLibraryAudioElement({
name,
duration,
startTime,
- trimStart: 0,
- trimEnd: 0,
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration,
- volume: DEFAULTS.element.volume,
- muted: false,
+ params: buildDefaultElementParams({ type: "audio" }),
};
if (buffer) {
element.buffer = buffer;
@@ -410,8 +393,8 @@ export function getElementFontFamilies({
const families = new Set<string>();
for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
for (const element of track.elements) {
- if (element.type === "text" && element.fontFamily) {
- families.add(element.fontFamily);
+ if (element.type === "text" && typeof element.params.fontFamily === "string") {
+ families.add(element.params.fontFamily);
}
if ("masks" in element) {
for (const mask of element.masks ?? []) {
diff --git a/apps/web/src/timeline/group-move/build-group.ts b/apps/web/src/timeline/group-move/build-group.ts
index 00f39d55..3e19e3e3 100644
--- a/apps/web/src/timeline/group-move/build-group.ts
+++ b/apps/web/src/timeline/group-move/build-group.ts
@@ -2,6 +2,7 @@ import type { ElementRef, SceneTracks } from "@/timeline";
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
import type { GroupMember, MoveGroup } from "./types";
import { getTrackPlacementById } from "./track-placement";
+import { subMediaTime } from "@/wasm";
export function buildMoveGroup({
anchorRef,
@@ -59,7 +60,10 @@ export function buildMoveGroup({
elementId: element.id,
elementType: element.type,
duration: element.duration,
- timeOffset: element.startTime - anchorElement.startTime,
+ timeOffset: subMediaTime({
+ a: element.startTime,
+ b: anchorElement.startTime,
+ }),
trackSection: placement.section,
sectionIndex: placement.sectionIndex,
displayIndex: placement.displayIndex,
diff --git a/apps/web/src/timeline/group-move/resolve-move.ts b/apps/web/src/timeline/group-move/resolve-move.ts
index 60940315..8307aad0 100644
--- a/apps/web/src/timeline/group-move/resolve-move.ts
+++ b/apps/web/src/timeline/group-move/resolve-move.ts
@@ -12,6 +12,13 @@ import {
getTrackPlacementByDisplayIndex,
getTrackPlacementById,
} from "./track-placement";
+import {
+ addMediaTime,
+ maxMediaTime,
+ type MediaTime,
+ subMediaTime,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
type GroupMoveTarget =
| {
@@ -32,7 +39,7 @@ export function resolveGroupMove({
}: {
group: MoveGroup;
tracks: SceneTracks;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
target: GroupMoveTarget;
}): GroupMoveResult | null {
if (target.kind === "newTracks") {
@@ -61,7 +68,7 @@ function resolveExistingTrackMove({
}: {
group: MoveGroup;
tracks: SceneTracks;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
anchorTargetTrackId: string;
}): GroupMoveResult | null {
const anchorTargetPlacement = getTrackPlacementById({
@@ -93,7 +100,10 @@ function resolveExistingTrackMove({
targetTrackId:
targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
elementId: member.elementId,
- newStartTime: clampedAnchorStartTime + member.timeOffset,
+ newStartTime: addMediaTime({
+ a: clampedAnchorStartTime,
+ b: member.timeOffset,
+ }),
}));
if (!canApplyMovesToExistingTracks({ tracks, moves })) {
@@ -119,7 +129,7 @@ function resolveNewTrackMove({
}: {
group: MoveGroup;
tracks: SceneTracks;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
anchorInsertIndex: number;
newTrackIds: string[];
}): GroupMoveResult | null {
@@ -173,7 +183,10 @@ function resolveNewTrackMove({
sourceTrackId: member.trackId,
targetTrackId: newTrackIds[memberIndex],
elementId: member.elementId,
- newStartTime: clampedAnchorStartTime + member.timeOffset,
+ newStartTime: addMediaTime({
+ a: clampedAnchorStartTime,
+ b: member.timeOffset,
+ }),
}));
return {
@@ -335,17 +348,26 @@ function clampAnchorStartTime({
}: {
group: MoveGroup;
tracks: SceneTracks;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
targetTrackIdsByElementId: Map<string, string>;
-}): number {
- const minimumAnchorStartTime = Math.max(
- 0,
- ...group.members.map((member) => -member.timeOffset),
- );
- let clampedAnchorStartTime = Math.max(
- minimumAnchorStartTime,
- anchorStartTime,
+}): MediaTime {
+ const minimumAnchorStartTime = group.members.reduce(
+ (minimumStartTime, member) =>
+ member.timeOffset < ZERO_MEDIA_TIME
+ ? maxMediaTime({
+ a: minimumStartTime,
+ b: subMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: member.timeOffset,
+ }),
+ })
+ : minimumStartTime,
+ ZERO_MEDIA_TIME,
);
+ let clampedAnchorStartTime =
+ anchorStartTime < minimumAnchorStartTime
+ ? minimumAnchorStartTime
+ : anchorStartTime;
const memberOnMainTrack = group.members.find(
(member) =>
@@ -358,11 +380,13 @@ function clampAnchorStartTime({
const movingElementIds = new Set(
group.members.map((member) => member.elementId),
);
- const requestedMainStartTime =
- clampedAnchorStartTime + memberOnMainTrack.timeOffset;
+ const requestedMainStartTime = addMediaTime({
+ a: clampedAnchorStartTime,
+ b: memberOnMainTrack.timeOffset,
+ });
const earliestStationaryMainStartTime = tracks.main.elements
.filter((element) => !movingElementIds.has(element.id))
- .reduce<number | null>((earliestStartTime, element) => {
+ .reduce<MediaTime | null>((earliestStartTime, element) => {
if (earliestStartTime == null || element.startTime < earliestStartTime) {
return element.startTime;
}
@@ -373,10 +397,13 @@ function clampAnchorStartTime({
earliestStationaryMainStartTime == null ||
requestedMainStartTime <= earliestStationaryMainStartTime
) {
- clampedAnchorStartTime = Math.max(
- minimumAnchorStartTime,
- -memberOnMainTrack.timeOffset,
- );
+ clampedAnchorStartTime = maxMediaTime({
+ a: minimumAnchorStartTime,
+ b: subMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: memberOnMainTrack.timeOffset,
+ }),
+ });
}
return clampedAnchorStartTime;
@@ -422,7 +449,7 @@ function canApplyMovesToExistingTracks({
const sourceElement = sourceElements.get(move.elementId);
return {
startTime: move.newStartTime,
- duration: sourceElement?.duration ?? 0,
+ duration: sourceElement?.duration ?? ZERO_MEDIA_TIME,
};
});
if (hasOverlappingTimeSpans({ timeSpans })) {
diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts
index 7f431e6e..7aaf4423 100644
--- a/apps/web/src/timeline/group-move/snap.ts
+++ b/apps/web/src/timeline/group-move/snap.ts
@@ -9,6 +9,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points";
import type { MoveGroup } from "./types";
+import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm";
export function snapGroupEdges({
group,
@@ -18,12 +19,12 @@ export function snapGroupEdges({
zoomLevel,
}: {
group: MoveGroup;
- anchorStartTime: number;
+ anchorStartTime: MediaTime;
tracks: SceneTracks;
- playheadTime: number;
+ playheadTime: MediaTime;
zoomLevel: number;
}): {
- snappedAnchorStartTime: number;
+ snappedAnchorStartTime: MediaTime;
snapPoint: SnapPoint | null;
} {
const excludeElementIds = new Set(
@@ -47,7 +48,10 @@ export function snapGroupEdges({
let snapPoint: SnapPoint | null = null;
for (const member of group.members) {
- const memberStartTime = anchorStartTime + member.timeOffset;
+ const memberStartTime = addMediaTime({
+ a: anchorStartTime,
+ b: member.timeOffset,
+ });
const memberStartSnap = resolveTimelineSnap({
targetTime: memberStartTime,
snapPoints,
@@ -58,12 +62,18 @@ export function snapGroupEdges({
memberStartSnap.snapDistance < closestSnapDistance
) {
closestSnapDistance = memberStartSnap.snapDistance;
- snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset;
+ snappedAnchorStartTime = subMediaTime({
+ a: memberStartSnap.snappedTime,
+ b: member.timeOffset,
+ });
snapPoint = memberStartSnap.snapPoint;
}
const memberEndSnap = resolveTimelineSnap({
- targetTime: memberStartTime + member.duration,
+ targetTime: addMediaTime({
+ a: memberStartTime,
+ b: member.duration,
+ }),
snapPoints,
maxSnapDistance,
});
@@ -72,8 +82,13 @@ export function snapGroupEdges({
memberEndSnap.snapDistance < closestSnapDistance
) {
closestSnapDistance = memberEndSnap.snapDistance;
- snappedAnchorStartTime =
- memberEndSnap.snappedTime - member.duration - member.timeOffset;
+ snappedAnchorStartTime = subMediaTime({
+ a: subMediaTime({
+ a: memberEndSnap.snappedTime,
+ b: member.duration,
+ }),
+ b: member.timeOffset,
+ });
snapPoint = memberEndSnap.snapPoint;
}
}
diff --git a/apps/web/src/timeline/group-move/types.ts b/apps/web/src/timeline/group-move/types.ts
index daf9c6e9..f97b698c 100644
--- a/apps/web/src/timeline/group-move/types.ts
+++ b/apps/web/src/timeline/group-move/types.ts
@@ -1,11 +1,12 @@
import type { ElementRef, ElementType, TrackType } from "@/timeline";
+import type { MediaTime } from "@/wasm";
export type GroupTrackSection = "overlay" | "main" | "audio";
export interface GroupMember extends ElementRef {
elementType: ElementType;
- duration: number;
- timeOffset: number;
+ duration: MediaTime;
+ timeOffset: MediaTime;
trackSection: GroupTrackSection;
sectionIndex: number;
displayIndex: number;
@@ -26,7 +27,7 @@ export interface PlannedElementMove {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
- newStartTime: number;
+ newStartTime: MediaTime;
}
export interface GroupMoveResult {
diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts
index d22b6891..ed335997 100644
--- a/apps/web/src/timeline/group-resize/compute-resize.ts
+++ b/apps/web/src/timeline/group-resize/compute-resize.ts
@@ -1,9 +1,20 @@
-import { roundToFrame } from "opencut-wasm";
import {
getSourceSpanAtClipTime,
getTimelineDurationForSourceSpan,
} from "@/retime";
-import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ addMediaTime,
+ clampMediaTime,
+ maxMediaTime,
+ type MediaTime,
+ mediaTime,
+ minMediaTime,
+ roundFrameTicks,
+ roundMediaTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
import type {
ComputeGroupResizeArgs,
GroupResizeMember,
@@ -15,63 +26,88 @@ import type {
export function computeGroupResize({
members,
side,
- deltaTicks,
+ deltaTime,
fps,
}: ComputeGroupResizeArgs): GroupResizeResult {
- const minDuration = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
- const minimumDeltaTicks = Math.max(
- ...members.map((member) =>
- getMinimumAllowedDeltaTicks({
- member,
- side,
- minDuration,
- }),
- ),
- );
- const maximumDeltaTicks = Math.min(
- ...members.map((member) =>
- getMaximumAllowedDeltaTicks({
+ if (members.length === 0) {
+ return { deltaTime: ZERO_MEDIA_TIME, updates: [] };
+ }
+
+ const minDuration = mediaTime({
+ ticks: Math.round((TICKS_PER_SECOND * fps.denominator) / fps.numerator),
+ });
+ let minimumDeltaTime = getMinimumAllowedDeltaTime({
+ member: members[0],
+ side,
+ minDuration,
+ });
+ let maximumDeltaTime = getMaximumAllowedDeltaTime({
+ member: members[0],
+ side,
+ minDuration,
+ });
+
+ for (const member of members.slice(1)) {
+ minimumDeltaTime = maxMediaTime({
+ a: minimumDeltaTime,
+ b: getMinimumAllowedDeltaTime({
member,
side,
minDuration,
}),
- ),
- );
- const clampedDeltaTicks =
- minimumDeltaTicks > maximumDeltaTicks
- ? minimumDeltaTicks
- : Math.min(maximumDeltaTicks, Math.max(minimumDeltaTicks, deltaTicks));
+ });
+ const memberMaximum = getMaximumAllowedDeltaTime({
+ member,
+ side,
+ minDuration,
+ });
+ if (memberMaximum !== null) {
+ maximumDeltaTime =
+ maximumDeltaTime === null
+ ? memberMaximum
+ : minMediaTime({ a: maximumDeltaTime, b: memberMaximum });
+ }
+ }
+
+ const clampedDeltaTime =
+ maximumDeltaTime === null
+ ? maxMediaTime({ a: minimumDeltaTime, b: deltaTime })
+ : clampMediaTime({
+ time: deltaTime,
+ min: minimumDeltaTime,
+ max: maximumDeltaTime,
+ });
// Snap the drag delta to a frame exactly once, then derive every patch
// field from that single snapped value. This keeps the invariant
// `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same
// delta is added on one side of the element and removed from the other,
- // so the rounding cancels by construction. Rounding each field
- // independently would break this — the individual rounds don't compose
- // when `sourceDuration` isn't frame-aligned.
- const snappedDeltaTicks =
- roundToFrame({ time: clampedDeltaTicks, rate: fps }) ?? clampedDeltaTicks;
+ // so the rounding cancels by construction. Per-field rounding (the old
+ // approach) couldn't preserve this because the individual rounds don't
+ // compose when `sourceDuration` isn't frame-aligned.
+ const snappedDeltaTime = mediaTime({
+ ticks: roundFrameTicks({ ticks: clampedDeltaTime, fps }),
+ });
// Re-clamp after rounding. Bounds derived from other elements are
// frame-aligned, so this is normally a no-op; at the source-extent limit
// the bound may not be frame-aligned, and honouring the bound takes
// precedence over frame alignment (you can't extend past real content).
- const finalDeltaTicks =
- minimumDeltaTicks > maximumDeltaTicks
- ? minimumDeltaTicks
- : Math.min(
- maximumDeltaTicks,
- Math.max(minimumDeltaTicks, snappedDeltaTicks),
- );
+ const finalDeltaTime =
+ maximumDeltaTime === null
+ ? maxMediaTime({ a: minimumDeltaTime, b: snappedDeltaTime })
+ : clampMediaTime({
+ time: snappedDeltaTime,
+ min: minimumDeltaTime,
+ max: maximumDeltaTime,
+ });
return {
- deltaTicks: Object.is(finalDeltaTicks, -0) ? 0 : finalDeltaTicks,
+ deltaTime: Object.is(finalDeltaTime, -0) ? ZERO_MEDIA_TIME : finalDeltaTime,
updates: members.map((member) =>
buildResizeUpdate({
member,
side,
- deltaTicks: finalDeltaTicks,
+ deltaTime: finalDeltaTime,
}),
),
};
@@ -80,27 +116,30 @@ export function computeGroupResize({
function buildResizeUpdate({
member,
side,
- deltaTicks,
+ deltaTime,
}: {
member: GroupResizeMember;
side: ResizeSide;
- deltaTicks: number;
+ deltaTime: MediaTime;
}): GroupResizeUpdate {
const sourceDelta = getSourceDeltaForClipDelta({
member,
- clipDelta: deltaTicks,
+ clipDelta: deltaTime,
});
if (side === "left") {
return {
trackId: member.trackId,
elementId: member.elementId,
- patch: {
- trimStart: Math.max(0, member.trimStart + sourceDelta),
- trimEnd: member.trimEnd,
- startTime: member.startTime + deltaTicks,
- duration: member.duration - deltaTicks,
- },
+ patch: {
+ trimStart: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: addMediaTime({ a: member.trimStart, b: sourceDelta }),
+ }),
+ trimEnd: member.trimEnd,
+ startTime: addMediaTime({ a: member.startTime, b: deltaTime }),
+ duration: subMediaTime({ a: member.duration, b: deltaTime }),
+ },
};
}
@@ -109,72 +148,95 @@ function buildResizeUpdate({
elementId: member.elementId,
patch: {
trimStart: member.trimStart,
- trimEnd: Math.max(0, member.trimEnd - sourceDelta),
+ trimEnd: maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({ a: member.trimEnd, b: sourceDelta }),
+ }),
startTime: member.startTime,
- duration: member.duration + deltaTicks,
+ duration: addMediaTime({ a: member.duration, b: deltaTime }),
},
};
}
-function getMinimumAllowedDeltaTicks({
+function getMinimumAllowedDeltaTime({
member,
side,
minDuration,
}: {
member: GroupResizeMember;
side: ResizeSide;
- minDuration: number;
-}): number {
+ minDuration: MediaTime;
+}): MediaTime {
if (side === "right") {
- return minDuration - member.duration;
+ return subMediaTime({ a: minDuration, b: member.duration });
}
- const leftNeighborFloor = Number.isFinite(member.leftNeighborBound)
- ? member.leftNeighborBound - member.startTime
- : -member.startTime;
+ const leftNeighborFloor =
+ member.leftNeighborBound !== null
+ ? subMediaTime({ a: member.leftNeighborBound, b: member.startTime })
+ : subMediaTime({ a: ZERO_MEDIA_TIME, b: member.startTime });
if (member.sourceDuration == null) {
return leftNeighborFloor;
}
- const maximumSourceExtension =
- getDurationForVisibleSourceSpan({
+ const maximumSourceExtension = subMediaTime({
+ a: getDurationForVisibleSourceSpan({
member,
- sourceSpan:
- getVisibleSourceSpanForDuration({
+ sourceSpan: addMediaTime({
+ a: getVisibleSourceSpanForDuration({
member,
duration: member.duration,
- }) + member.trimStart,
- }) - member.duration;
- return Math.max(leftNeighborFloor, -maximumSourceExtension);
+ }),
+ b: member.trimStart,
+ }),
+ }),
+ b: member.duration,
+ });
+ return maxMediaTime({
+ a: leftNeighborFloor,
+ b: subMediaTime({ a: ZERO_MEDIA_TIME, b: maximumSourceExtension }),
+ });
}
-function getMaximumAllowedDeltaTicks({
+function getMaximumAllowedDeltaTime({
member,
side,
minDuration,
}: {
member: GroupResizeMember;
side: ResizeSide;
- minDuration: number;
-}): number {
+ minDuration: MediaTime;
+}): MediaTime | null {
if (side === "left") {
- return member.duration - minDuration;
+ return subMediaTime({ a: member.duration, b: minDuration });
}
- const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound)
- ? member.rightNeighborBound - (member.startTime + member.duration)
- : Infinity;
+ const rightNeighborCeiling =
+ member.rightNeighborBound === null
+ ? null
+ : subMediaTime({
+ a: member.rightNeighborBound,
+ b: addMediaTime({ a: member.startTime, b: member.duration }),
+ });
if (member.sourceDuration == null) {
return rightNeighborCeiling;
}
- const maximumVisibleSourceSpan =
- getSourceDuration({ member }) - member.trimStart;
+ const maximumVisibleSourceSpan = subMediaTime({
+ a: getSourceDuration({ member }),
+ b: member.trimStart,
+ });
const maximumDuration = getDurationForVisibleSourceSpan({
member,
sourceSpan: maximumVisibleSourceSpan,
});
- return Math.min(rightNeighborCeiling, maximumDuration - member.duration);
+ const sourceDurationCeiling = subMediaTime({
+ a: maximumDuration,
+ b: member.duration,
+ });
+ return rightNeighborCeiling === null
+ ? sourceDurationCeiling
+ : minMediaTime({ a: rightNeighborCeiling, b: sourceDurationCeiling });
}
function getSourceDeltaForClipDelta({
@@ -182,21 +244,23 @@ function getSourceDeltaForClipDelta({
clipDelta,
}: {
member: GroupResizeMember;
- clipDelta: number;
-}): number {
+ clipDelta: MediaTime;
+}): MediaTime {
if (!member.retime) {
return clipDelta;
}
- return clipDelta >= 0
- ? getSourceSpanAtClipTime({
- clipTime: clipDelta,
- retime: member.retime,
- })
- : -getSourceSpanAtClipTime({
- clipTime: Math.abs(clipDelta),
- retime: member.retime,
- });
+ const sourceDelta =
+ clipDelta >= 0
+ ? getSourceSpanAtClipTime({
+ clipTime: clipDelta,
+ retime: member.retime,
+ })
+ : -getSourceSpanAtClipTime({
+ clipTime: Math.abs(clipDelta),
+ retime: member.retime,
+ });
+ return roundMediaTime({ time: sourceDelta });
}
function getVisibleSourceSpanForDuration({
@@ -204,15 +268,17 @@ function getVisibleSourceSpanForDuration({
duration,
}: {
member: GroupResizeMember;
- duration: number;
-}): number {
+ duration: MediaTime;
+}): MediaTime {
if (!member.retime) {
return duration;
}
- return getSourceSpanAtClipTime({
- clipTime: duration,
- retime: member.retime,
+ return roundMediaTime({
+ time: getSourceSpanAtClipTime({
+ clipTime: duration,
+ retime: member.retime,
+ }),
});
}
@@ -221,29 +287,33 @@ function getDurationForVisibleSourceSpan({
sourceSpan,
}: {
member: GroupResizeMember;
- sourceSpan: number;
-}): number {
+ sourceSpan: MediaTime;
+}): MediaTime {
if (!member.retime) {
return sourceSpan;
}
- return getTimelineDurationForSourceSpan({
- sourceSpan,
- retime: member.retime,
+ return roundMediaTime({
+ time: getTimelineDurationForSourceSpan({
+ sourceSpan,
+ retime: member.retime,
+ }),
});
}
-function getSourceDuration({ member }: { member: GroupResizeMember }): number {
- if (typeof member.sourceDuration === "number") {
+function getSourceDuration({ member }: { member: GroupResizeMember }): MediaTime {
+ if (member.sourceDuration != null) {
return member.sourceDuration;
}
- return (
- member.trimStart +
- getVisibleSourceSpanForDuration({
+ return addMediaTime({
+ a: addMediaTime({
+ a: member.trimStart,
+ b: getVisibleSourceSpanForDuration({
member,
duration: member.duration,
- }) +
- member.trimEnd
- );
+ }),
+ }),
+ b: member.trimEnd,
+ });
}
diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts
index 511358ba..5c0f77f6 100644
--- a/apps/web/src/timeline/group-resize/types.ts
+++ b/apps/web/src/timeline/group-resize/types.ts
@@ -1,36 +1,37 @@
import type { FrameRate } from "opencut-wasm";
import type { ElementRef, RetimeConfig } from "@/timeline/types";
+import type { MediaTime } from "@/wasm";
export type ResizeSide = "left" | "right";
export interface GroupResizeMember extends ElementRef {
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- sourceDuration?: number;
+ startTime: MediaTime;
+ duration: MediaTime;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+ sourceDuration?: MediaTime;
retime?: RetimeConfig;
- leftNeighborBound: number;
- rightNeighborBound: number;
+ leftNeighborBound: MediaTime | null;
+ rightNeighborBound: MediaTime | null;
}
export interface GroupResizeUpdate extends ElementRef {
patch: {
- trimStart: number;
- trimEnd: number;
- startTime: number;
- duration: number;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+ startTime: MediaTime;
+ duration: MediaTime;
};
}
export interface GroupResizeResult {
- deltaTicks: number;
+ deltaTime: MediaTime;
updates: GroupResizeUpdate[];
}
export interface ComputeGroupResizeArgs {
members: GroupResizeMember[];
side: ResizeSide;
- deltaTicks: number;
+ deltaTime: MediaTime;
fps: FrameRate;
}
diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts
index 26a21f85..220f3fec 100644
--- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts
+++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts
@@ -1,5 +1,6 @@
-import { useEffect, useReducer, useRef, type RefObject } from "react";
+import { useEffect, useReducer, useState, type RefObject } from "react";
import { useEditor } from "@/editor/use-editor";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
import { registerCanceller } from "@/editor/cancel-interaction";
@@ -63,17 +64,10 @@ export function useElementInteraction({
onChange: onSnapPointChange,
},
};
-
- const depsRef = useRef<ElementInteractionDeps>(deps);
- depsRef.current = deps;
-
- const controllerRef = useRef<ElementInteractionController | null>(null);
- if (!controllerRef.current) {
- controllerRef.current = new ElementInteractionController({
- depsRef: depsRef as ElementInteractionDepsRef,
- });
- }
- const controller = controllerRef.current;
+ const depsRef = useCommittedRef(deps) as ElementInteractionDepsRef;
+ const [controller] = useState(
+ () => new ElementInteractionController({ depsRef }),
+ );
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => controller.subscribe(rerender), [controller]);
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
index 5a1ff1f0..141bba8f 100644
--- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
+++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
@@ -1,5 +1,6 @@
-import { useEffect, useReducer, useRef } from "react";
+import { useEffect, useReducer, useState } from "react";
import { useEditor } from "@/editor/use-editor";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useKeyframeSelection } from "./use-keyframe-selection";
import { registerCanceller } from "@/editor/cancel-interaction";
import {
@@ -8,6 +9,7 @@ import {
type KeyframeDragState,
} from "@/timeline/controllers/keyframe-drag-controller";
import type { TimelineElement } from "@/timeline";
+import type { MediaTime } from "@/wasm";
export type { KeyframeDragState };
@@ -18,7 +20,7 @@ export function useKeyframeDrag({
}: {
zoomLevel: number;
element: TimelineElement;
- displayedStartTime: number;
+ displayedStartTime: MediaTime;
}) {
const editor = useEditor();
const {
@@ -40,18 +42,13 @@ export function useKeyframeDrag({
toggleKeyframeSelection,
selectKeyframeRange,
executeCommand: (command) => editor.command.execute({ command }),
- seek: (args) => editor.playback.seek(args),
+ seek: ({ time }) => editor.playback.seek({ time }),
getTotalDuration: () => editor.timeline.getTotalDuration(),
};
-
- const configRef = useRef<KeyframeDragConfig>(config);
- configRef.current = config;
-
- const controllerRef = useRef<KeyframeDragController | null>(null);
- if (!controllerRef.current) {
- controllerRef.current = new KeyframeDragController({ configRef });
- }
- const controller = controllerRef.current;
+ const configRef = useCommittedRef(config);
+ const [controller] = useState(
+ () => new KeyframeDragController({ configRef }),
+ );
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => controller.subscribe(rerender), [controller]);
diff --git a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts
index 2081b939..ac6d91eb 100644
--- a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts
+++ b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts
@@ -1,6 +1,7 @@
-import { useEffect, useState } from "react";
+import { useContainerSize } from "@/hooks/use-container-size";
import { timelineTimeToSnappedPixels } from "@/timeline";
import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/timeline/components/layout";
+import { useScrollPosition } from "./use-scroll-position";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
@@ -20,24 +21,9 @@ export function useSnapIndicatorPosition({
timelineRef,
tracksScrollRef,
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
- const [scrollLeft, setScrollLeft] = useState(0);
-
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
-
- if (!tracksViewport) return;
-
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
-
- setScrollLeft(tracksViewport.scrollLeft);
-
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
-
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const { height: timelineHeight } = useContainerSize({ containerRef: timelineRef });
+ const { scrollLeft } = useScrollPosition({ scrollRef: tracksScrollRef });
+ const timelineContainerHeight = timelineHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
const timelinePosition = timelineTimeToSnappedPixels({
diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
index 4ec2b80b..c405819e 100644
--- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
@@ -1,5 +1,6 @@
-import { useEffect, useReducer, useRef, type RefObject } from "react";
+import { useEffect, useReducer, useState, type RefObject } from "react";
import { useEditor } from "@/editor/use-editor";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import {
DragDropController,
type DragDropConfig,
@@ -37,15 +38,8 @@ export function useTimelineDragDrop({
insertElement: (args) => editor.timeline.insertElement(args),
addClipEffect: (args) => editor.timeline.addClipEffect(args),
};
-
- const configRef = useRef<DragDropConfig>(config);
- configRef.current = config;
-
- const controllerRef = useRef<DragDropController | null>(null);
- if (!controllerRef.current) {
- controllerRef.current = new DragDropController({ configRef });
- }
- const controller = controllerRef.current;
+ const configRef = useCommittedRef(config);
+ const [controller] = useState(() => new DragDropController({ configRef }));
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => controller.subscribe(rerender), [controller]);
diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
index 12bfe76a..9f10c4a5 100644
--- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
@@ -1,5 +1,6 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useState } from "react";
import { useEditor } from "@/editor/use-editor";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
import { timelineTimeToPixels } from "@/timeline";
@@ -7,6 +8,7 @@ import {
PlayheadController,
type PlayheadConfig,
} from "@/timeline/controllers/playhead-controller";
+import type { MediaTime } from "@/wasm";
interface UseTimelinePlayheadProps {
zoomLevel: number;
@@ -44,17 +46,17 @@ export function useTimelinePlayhead({
seek: (time) => editor.playback.seek({ time }),
setScrubbing: (scrubbing) =>
editor.playback.setScrubbing({ isScrubbing: scrubbing }),
- setTimelineViewState: (viewState) =>
- editor.project.setTimelineViewState({ viewState }),
+ setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft,
+ playheadTime,
+ },
+ }),
};
- const configRef = useRef(config);
- configRef.current = config;
-
- const ctrlRef = useRef<PlayheadController | null>(null);
- if (!ctrlRef.current) {
- ctrlRef.current = new PlayheadController({ configRef });
- }
- const ctrl = ctrlRef.current;
+ const configRef = useCommittedRef(config);
+ const [ctrl] = useState(() => new PlayheadController({ configRef }));
// Scroll → keep playhead position in sync with scroll offset.
useEffect(() => {
@@ -68,7 +70,7 @@ export function useTimelinePlayhead({
// Playback events → update playhead position and auto-scroll during playback.
useEffect(() => {
- const handler = (time: number) => ctrl.handlePlaybackUpdate(time);
+ const handler = (time: MediaTime) => ctrl.handlePlaybackUpdate(time);
ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
const unsubscribeUpdate = editor.playback.onUpdate(handler);
const unsubscribeSeek = editor.playback.onSeek(handler);
diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts
index 46363fb6..6bd0c606 100644
--- a/apps/web/src/timeline/hooks/use-timeline-resize.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts
@@ -1,5 +1,6 @@
-import { useEffect, useReducer, useRef } from "react";
+import { useEffect, useReducer, useState } from "react";
import { useEditor } from "@/editor/use-editor";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
import { useTimelineStore } from "@/timeline/timeline-store";
@@ -10,6 +11,7 @@ import {
} from "@/timeline/controllers/resize-controller";
import type { ResizeSide } from "@/timeline/group-resize";
import type { SnapPoint } from "@/timeline/snapping";
+import type { TimelineElement } from "@/timeline";
export type { ResizeSide };
@@ -41,7 +43,7 @@ export function useTimelineResize({
updates: updates.map(({ trackId, elementId, patch }) => ({
trackId,
elementId,
- updates: patch,
+ updates: patch as Partial<TimelineElement>,
})),
}),
commitElements: (updates) =>
@@ -49,20 +51,13 @@ export function useTimelineResize({
updates: updates.map(({ trackId, elementId, patch }) => ({
trackId,
elementId,
- patch,
+ patch: patch as Partial<TimelineElement>,
})),
}),
onSnapPointChange,
};
-
- const configRef = useRef(config);
- configRef.current = config;
-
- const controllerRef = useRef<ResizeController | null>(null);
- if (!controllerRef.current) {
- controllerRef.current = new ResizeController({ configRef });
- }
- const controller = controllerRef.current;
+ const configRef = useCommittedRef(config);
+ const [controller] = useState(() => new ResizeController({ configRef }));
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => controller.subscribe(rerender), [controller]);
diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts
index 56f68ed6..ea9b44a7 100644
--- a/apps/web/src/timeline/hooks/use-timeline-seek.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts
@@ -1,9 +1,11 @@
-import { useEffect, useRef, type RefObject } from "react";
+import { useEffect, useState, type RefObject } from "react";
import { useEditor } from "@/editor/use-editor";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import {
SeekController,
type SeekConfig,
} from "@/timeline/controllers/seek-controller";
+import type { MediaTime } from "@/wasm";
interface UseTimelineSeekProps {
playheadRef: RefObject<HTMLDivElement | null>;
@@ -11,10 +13,10 @@ interface UseTimelineSeekProps {
rulerScrollRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
- duration: number;
+ duration: MediaTime;
isSelecting: boolean;
clearSelectedElements: () => void;
- seek: (time: number) => void;
+ seek: (time: MediaTime) => void;
}
export function useTimelineSeek({
@@ -40,18 +42,17 @@ export function useTimelineSeek({
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
clearSelectedElements,
seek,
- setTimelineViewState: (viewState) =>
- editor.project.setTimelineViewState({ viewState }),
+ setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft,
+ playheadTime,
+ },
+ }),
};
-
- const configRef = useRef(config);
- configRef.current = config;
-
- const controllerRef = useRef<SeekController | null>(null);
- if (!controllerRef.current) {
- controllerRef.current = new SeekController({ configRef });
- }
- const controller = controllerRef.current;
+ const configRef = useCommittedRef(config);
+ const [controller] = useState(() => new SeekController({ configRef }));
useEffect(() => () => controller.destroy(), [controller]);
diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
index a8825a37..60768963 100644
--- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
@@ -4,21 +4,23 @@ import {
useEffect,
useLayoutEffect,
useReducer,
- useRef,
+ useState,
} from "react";
import { useEditor } from "@/editor/use-editor";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { TIMELINE_ZOOM_MIN } from "@/timeline/scale";
import {
ZoomController,
type ZoomConfig,
} from "@/timeline/controllers/zoom-controller";
+import type { MediaTime } from "@/wasm";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement | null>;
minZoom?: number;
initialZoom?: number;
initialScrollLeft?: number;
- initialPlayheadTime?: number;
+ initialPlayheadTime?: MediaTime;
tracksScrollRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>;
}
@@ -47,24 +49,26 @@ export function useTimelineZoom({
getRulerScrollEl: () => rulerScrollRef.current,
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
seek: (time) => editor.playback.seek({ time }),
- setTimelineViewState: (viewState) =>
- editor.project.setTimelineViewState({ viewState }),
+ setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft,
+ playheadTime,
+ },
+ }),
};
- const configRef = useRef(config);
- configRef.current = config;
-
- const controllerRef = useRef<ZoomController | null>(null);
- if (!controllerRef.current) {
- controllerRef.current = new ZoomController({ configRef, initialZoom });
- }
- const controller = controllerRef.current;
+ const configRef = useCommittedRef(config);
+ const [controller] = useState(
+ () => new ZoomController({ configRef, initialZoom }),
+ );
const zoomLevel = controller.zoomLevel;
const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => controller.subscribe(rerender), [controller]);
useEffect(() => {
- controller.reconcileInitialAndMinZoom(minZoom, initialZoom);
+ controller.reconcileInitialAndMinZoom({ minZoom, initialZoom });
}, [controller, minZoom, initialZoom]);
useLayoutEffect(() => {
diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts
index 5ee079b1..21944561 100644
--- a/apps/web/src/timeline/index.ts
+++ b/apps/web/src/timeline/index.ts
@@ -1,29 +1,33 @@
-import type { SceneTracks } from "./types";
-
-export * from "./types";
-export * from "./drag";
-export * from "./track-capabilities";
-export * from "./track-element-update";
-export * from "./element-utils";
-export * from "./audio-separation";
-export * from "./zoom-utils";
-export * from "./ruler-utils";
-export * from "./pixel-utils";
-
-export function calculateTotalDuration({
- tracks,
-}: {
- tracks: SceneTracks;
-}): number {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- if (orderedTracks.length === 0) return 0;
-
- const trackEndTimes = orderedTracks.map((track) =>
- track.elements.reduce((maxEnd, element) => {
- const elementEnd = element.startTime + element.duration;
- return Math.max(maxEnd, elementEnd);
- }, 0),
- );
-
- return Math.max(...trackEndTimes, 0);
-}
+import { addMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
+import type { SceneTracks } from "./types";
+
+export * from "./types";
+export * from "./drag";
+export * from "./track-capabilities";
+export * from "./track-element-update";
+export * from "./element-utils";
+export * from "./audio-separation";
+export * from "./zoom-utils";
+export * from "./ruler-utils";
+export * from "./pixel-utils";
+
+export function calculateTotalDuration({
+ tracks,
+}: {
+ tracks: SceneTracks;
+}): MediaTime {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ if (orderedTracks.length === 0) return ZERO_MEDIA_TIME;
+
+ let maxEnd: MediaTime = ZERO_MEDIA_TIME;
+ for (const track of orderedTracks) {
+ for (const element of track.elements) {
+ const elementEnd = addMediaTime({
+ a: element.startTime,
+ b: element.duration,
+ });
+ if (elementEnd > maxEnd) maxEnd = elementEnd;
+ }
+ }
+ return maxEnd;
+}
diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts
index ebbfe578..202c9eb8 100644
--- a/apps/web/src/timeline/placement/__tests__/resolve.test.ts
+++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts
@@ -14,6 +14,7 @@ import type {
} from "@/timeline";
import type { Transform } from "@/rendering";
import { resolveTrackPlacement } from "@/timeline/placement";
+import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
function buildTransform(): Transform {
return {
@@ -67,11 +68,11 @@ function buildElement({
id,
type: "audio",
name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- volume: 1,
+ startTime: mediaTime({ ticks: startTime }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ params: { volume: 1, muted: false },
sourceType: "upload",
mediaId: `media-${id}`,
} satisfies AudioElement;
@@ -80,51 +81,66 @@ function buildElement({
id,
type: "graphic",
name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
+ startTime: mediaTime({ ticks: startTime }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
definitionId: `graphic-${id}`,
- params: {},
- transform: buildTransform(),
- opacity: 1,
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
} satisfies GraphicElement;
case "text":
return {
id,
type: "text",
name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- content: id,
- fontSize: 32,
- fontFamily: "sans-serif",
- color: "#ffffff",
- background: {
- enabled: false,
- color: "#000000",
+ startTime: mediaTime({ ticks: startTime }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ params: {
+ content: id,
+ fontSize: 32,
+ fontFamily: "sans-serif",
+ color: "#ffffff",
+ "background.enabled": false,
+ "background.color": "#000000",
+ textAlign: "left",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
},
- textAlign: "left",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- transform: buildTransform(),
- opacity: 1,
} satisfies TextElement;
case "video":
return {
id,
type: "video",
name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
+ startTime: mediaTime({ ticks: startTime }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
mediaId: `media-${id}`,
- transform: buildTransform(),
- opacity: 1,
+ params: {
+ "transform.positionX": 0,
+ "transform.positionY": 0,
+ "transform.scaleX": 1,
+ "transform.scaleY": 1,
+ "transform.rotate": 0,
+ opacity: 1,
+ },
} satisfies VideoElement;
}
@@ -224,7 +240,11 @@ function buildTimeSpan({
duration: number;
excludeElementId?: string;
}) {
- return { startTime, duration, excludeElementId };
+ return {
+ startTime: mediaTime({ ticks: startTime }),
+ duration: mediaTime({ ticks: duration }),
+ excludeElementId,
+ };
}
function buildSceneTracks({
diff --git a/apps/web/src/timeline/placement/main-track.ts b/apps/web/src/timeline/placement/main-track.ts
index ab2ab7bd..36632226 100644
--- a/apps/web/src/timeline/placement/main-track.ts
+++ b/apps/web/src/timeline/placement/main-track.ts
@@ -1,4 +1,5 @@
import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export const MAIN_TRACK_NAME = "Main Track";
@@ -31,9 +32,9 @@ export function enforceMainTrackStart({
}: {
tracks: SceneTracks;
targetTrackId: string;
- requestedStartTime: number;
+ requestedStartTime: MediaTime;
excludeElementId?: string;
-}): number {
+}): MediaTime {
if (tracks.main.id !== targetTrackId) {
return requestedStartTime;
}
@@ -43,11 +44,11 @@ export function enforceMainTrackStart({
excludeElementId,
});
if (!earliestElement) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
if (requestedStartTime <= earliestElement.startTime) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
return requestedStartTime;
diff --git a/apps/web/src/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts
index 26275392..680cb0bb 100644
--- a/apps/web/src/timeline/placement/resolve.ts
+++ b/apps/web/src/timeline/placement/resolve.ts
@@ -13,6 +13,7 @@ import type {
PlacementSubject,
PlacementTimeSpan,
} from "./types";
+import { ZERO_MEDIA_TIME } from "@/wasm";
type ResolveTrackPlacementParams = PlacementSubject & {
tracks: SceneTracks;
@@ -32,7 +33,7 @@ function buildExistingTrackResult({
timeSpans: PlacementTimeSpan[];
}): PlacementResult {
const firstSpan = timeSpans[0];
- const requestedStartTime = firstSpan?.startTime ?? 0;
+ const requestedStartTime = firstSpan?.startTime ?? ZERO_MEDIA_TIME;
const adjustedStartTime = enforceMainTrackStart({
tracks,
targetTrackId: track.id,
diff --git a/apps/web/src/timeline/placement/types.ts b/apps/web/src/timeline/placement/types.ts
index 6847ab36..748f34d7 100644
--- a/apps/web/src/timeline/placement/types.ts
+++ b/apps/web/src/timeline/placement/types.ts
@@ -1,8 +1,9 @@
import type { ElementType, TrackType } from "@/timeline";
+import type { MediaTime } from "@/wasm";
export interface PlacementTimeSpan {
- startTime: number;
- duration: number;
+ startTime: MediaTime;
+ duration: MediaTime;
excludeElementId?: string;
}
@@ -29,7 +30,7 @@ export type PlacementResult =
trackId: string;
trackIndex: number;
trackType: TrackType;
- adjustedStartTime?: number;
+ adjustedStartTime?: MediaTime;
}
| {
kind: "newTrack";
diff --git a/apps/web/src/timeline/playhead-snap-source.ts b/apps/web/src/timeline/playhead-snap-source.ts
index 88e21eba..0b41e17b 100644
--- a/apps/web/src/timeline/playhead-snap-source.ts
+++ b/apps/web/src/timeline/playhead-snap-source.ts
@@ -1,9 +1,10 @@
import type { SnapPoint } from "@/timeline/snapping";
+import type { MediaTime } from "@/wasm";
export function getPlayheadSnapPoints({
playheadTime,
}: {
- playheadTime: number;
+ playheadTime: MediaTime;
}): SnapPoint[] {
return [{ time: playheadTime, type: "playhead" }];
}
diff --git a/apps/web/src/timeline/scenes.ts b/apps/web/src/timeline/scenes.ts
index d48c943c..8aa1b6b5 100644
--- a/apps/web/src/timeline/scenes.ts
+++ b/apps/web/src/timeline/scenes.ts
@@ -2,6 +2,7 @@ import type { TScene } from "@/timeline";
import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/timeline";
import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null;
@@ -89,10 +90,10 @@ export function getProjectDurationFromScenes({
scenes,
}: {
scenes: TScene[];
-}): number {
+}): MediaTime {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
if (!mainScene?.tracks) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
return calculateTotalDuration({ tracks: mainScene.tracks });
diff --git a/apps/web/src/timeline/snapping/resolve.ts b/apps/web/src/timeline/snapping/resolve.ts
index 2f00d081..dcbc670d 100644
--- a/apps/web/src/timeline/snapping/resolve.ts
+++ b/apps/web/src/timeline/snapping/resolve.ts
@@ -1,11 +1,12 @@
import type { SnapPoint, SnapResult } from "./types";
+import type { MediaTime } from "@/wasm";
export function resolveTimelineSnap({
targetTime,
snapPoints,
maxSnapDistance,
}: {
- targetTime: number;
+ targetTime: MediaTime;
snapPoints: SnapPoint[];
maxSnapDistance: number;
}): SnapResult {
diff --git a/apps/web/src/timeline/snapping/types.ts b/apps/web/src/timeline/snapping/types.ts
index 2a6d0903..7bffd84f 100644
--- a/apps/web/src/timeline/snapping/types.ts
+++ b/apps/web/src/timeline/snapping/types.ts
@@ -1,3 +1,5 @@
+import type { MediaTime } from "@/wasm";
+
export type SnapPointType =
| "element-start"
| "element-end"
@@ -6,14 +8,14 @@ export type SnapPointType =
| "keyframe";
export interface SnapPoint {
- time: number;
+ time: MediaTime;
type: SnapPointType;
elementId?: string;
trackId?: string;
}
export interface SnapResult {
- snappedTime: number;
+ snappedTime: MediaTime;
snapPoint: SnapPoint | null;
snapDistance: number;
}
diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts
index 338da9d9..5b3e4d29 100644
--- a/apps/web/src/timeline/types.ts
+++ b/apps/web/src/timeline/types.ts
@@ -2,7 +2,7 @@ import type { ElementAnimations } from "@/animation/types";
import type { Effect } from "@/effects/types";
import type { Mask } from "@/masks/types";
import type { ParamValues } from "@/params";
-import type { BlendMode, Transform } from "@/rendering";
+import type { MediaTime } from "@/wasm";
export type ElementRef = {
trackId: string;
@@ -10,10 +10,10 @@ export type ElementRef = {
};
export interface Bookmark {
- time: number;
+ time: MediaTime;
note?: string;
color?: string;
- duration?: number;
+ duration?: MediaTime;
}
export interface TScene {
@@ -86,8 +86,6 @@ export interface RetimeConfig {
interface BaseAudioElement extends BaseTimelineElement {
type: "audio";
- volume: number;
- muted?: boolean;
buffer?: AudioBuffer;
retime?: RetimeConfig;
}
@@ -107,25 +105,21 @@ export type AudioElement = UploadAudioElement | LibraryAudioElement;
interface BaseTimelineElement {
id: string;
name: string;
- duration: number;
- startTime: number;
- trimStart: number;
- trimEnd: number;
- sourceDuration?: number;
+ duration: MediaTime;
+ startTime: MediaTime;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+ sourceDuration?: MediaTime;
animations?: ElementAnimations;
+ params: ParamValues;
}
export interface VideoElement extends BaseTimelineElement {
type: "video";
mediaId: string;
- volume?: number;
- muted?: boolean;
isSourceAudioEnabled?: boolean;
hidden?: boolean;
retime?: RetimeConfig;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
effects?: Effect[];
masks?: Mask[];
}
@@ -134,40 +128,13 @@ export interface ImageElement extends BaseTimelineElement {
type: "image";
mediaId: string;
hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
effects?: Effect[];
masks?: Mask[];
}
-export interface TextBackground {
- enabled: boolean;
- color: string;
- cornerRadius?: number;
- paddingX?: number;
- paddingY?: number;
- offsetX?: number;
- offsetY?: number;
-}
-
export interface TextElement extends BaseTimelineElement {
type: "text";
- content: string;
- fontSize: number;
- fontFamily: string;
- color: string;
- background: TextBackground;
- textAlign: "left" | "center" | "right";
- fontWeight: "normal" | "bold";
- fontStyle: "normal" | "italic";
- textDecoration: "none" | "underline" | "line-through";
- letterSpacing?: number;
- lineHeight?: number;
hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
effects?: Effect[];
}
@@ -178,20 +145,13 @@ export interface StickerElement extends BaseTimelineElement {
intrinsicWidth?: number;
intrinsicHeight?: number;
hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
effects?: Effect[];
}
export interface GraphicElement extends BaseTimelineElement {
type: "graphic";
definitionId: string;
- params: ParamValues;
hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
effects?: Effect[];
masks?: Mask[];
}
@@ -199,13 +159,9 @@ export interface GraphicElement extends BaseTimelineElement {
export interface EffectElement extends BaseTimelineElement {
type: "effect";
effectType: string;
- params: ParamValues;
}
-export type ElementUpdatePatch =
- | { transform: Transform }
- | { opacity: number }
- | { volume: number };
+export type ElementUpdatePatch = { params?: Partial<ParamValues> };
export type TimelineElement =
| AudioElement
@@ -269,18 +225,32 @@ export type CreateTimelineElement =
| CreateGraphicElement
| CreateEffectElement;
+export interface ElementDragState {
+ isDragging: boolean;
+ elementId: string | null;
+ dragElementIds: string[];
+ dragTimeOffsets: Record<string, MediaTime>;
+ trackId: string | null;
+ startMouseX: number;
+ startMouseY: number;
+ startElementTime: MediaTime;
+ clickOffsetTime: MediaTime;
+ currentTime: MediaTime;
+ currentMouseY: number;
+}
+
export type ElementDragView =
| { readonly kind: "idle" }
| {
readonly kind: "dragging";
readonly anchorElementId: string;
readonly trackId: string;
- readonly memberTimeOffsets: ReadonlyMap<string, number>;
+ readonly memberTimeOffsets: ReadonlyMap<string, MediaTime>;
readonly startMouseX: number;
readonly startMouseY: number;
- readonly startElementTime: number;
- readonly clickOffsetTime: number;
- readonly currentTime: number;
+ readonly startElementTime: MediaTime;
+ readonly clickOffsetTime: MediaTime;
+ readonly currentTime: MediaTime;
readonly currentMouseX: number;
readonly currentMouseY: number;
readonly dropTarget: DropTarget | null;
@@ -290,7 +260,7 @@ export interface DropTarget {
trackIndex: number;
isNewTrack: boolean;
insertPosition: "above" | "below" | null;
- xPosition: number;
+ xPosition: MediaTime;
targetElement: { elementId: string; trackId: string } | null;
}
@@ -299,13 +269,13 @@ export interface ComputeDropTargetParams {
mouseX: number;
mouseY: number;
tracks: SceneTracks;
- playheadTime: number;
+ playheadTime: MediaTime;
isExternalDrop: boolean;
- elementDuration: number;
+ elementDuration: MediaTime;
pixelsPerSecond: number;
zoomLevel: number;
verticalDragDirection?: "up" | "down" | null;
- startTimeOverride?: number;
+ startTimeOverride?: MediaTime;
excludeElementId?: string;
targetElementTypes?: string[];
}
diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts
index beacf897..590a8f34 100644
--- a/apps/web/src/timeline/update-pipeline.ts
+++ b/apps/web/src/timeline/update-pipeline.ts
@@ -6,6 +6,7 @@ import {
} from "@/retime";
import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline";
import { isRetimableElement } from "@/timeline";
+import { ZERO_MEDIA_TIME, roundMediaTime } from "@/wasm";
type ElementUpdateField = keyof TimelineElement | string;
@@ -61,9 +62,11 @@ const deriveRules: ElementUpdateRule[] = [
0,
sourceDuration - element.trimStart - element.trimEnd,
);
- const nextDuration = getTimelineDurationForSourceSpan({
- sourceSpan: visibleSourceSpan,
- retime: nextRetime,
+ const nextDuration = roundMediaTime({
+ time: getTimelineDurationForSourceSpan({
+ sourceSpan: visibleSourceSpan,
+ retime: nextRetime,
+ }),
});
return {
@@ -94,7 +97,10 @@ const enforceRules: ElementUpdateRule[] = [
{
triggers: ["startTime"],
apply: ({ element, context }) => {
- const requestedStartTime = Math.max(0, element.startTime);
+ const requestedStartTime =
+ element.startTime < ZERO_MEDIA_TIME
+ ? ZERO_MEDIA_TIME
+ : element.startTime;
if (context.trackId !== context.tracks.main.id) {
return {
element: {
@@ -118,7 +124,7 @@ const enforceRules: ElementUpdateRule[] = [
...element,
startTime:
!earliestElement || requestedStartTime <= earliestElement.startTime
- ? 0
+ ? ZERO_MEDIA_TIME
: requestedStartTime,
},
};
@@ -135,7 +141,14 @@ export function applyElementUpdate({
patch: Partial<TimelineElement>;
context: ElementUpdateContext;
}): TimelineElement {
- let nextElement = { ...element, ...patch } as TimelineElement;
+ let nextElement = {
+ ...element,
+ ...patch,
+ params: {
+ ...element.params,
+ ...(patch.params ?? {}),
+ },
+ } as TimelineElement;
const changedFields = new Set(
Object.keys(patch) as ElementUpdateField[],
);
diff --git a/apps/web/src/utils/geometry.ts b/apps/web/src/utils/geometry.ts
index 23fc4f46..b5c50584 100644
--- a/apps/web/src/utils/geometry.ts
+++ b/apps/web/src/utils/geometry.ts
@@ -5,8 +5,14 @@ export function dimensionToAspectRatio({
width: number;
height: number;
}): string {
- const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
- const divisor = gcd(width, height);
+ const gcd = ({
+ a,
+ b,
+ }: {
+ a: number;
+ b: number;
+ }): number => (b === 0 ? a : gcd({ a: b, b: a % b }));
+ const divisor = gcd({ a: width, b: height });
const aspectWidth = width / divisor;
const aspectHeight = height / divisor;
return `${aspectWidth}:${aspectHeight}`;
diff --git a/apps/web/src/wasm/index.ts b/apps/web/src/wasm/index.ts
index 60031a0e..c979b620 100644
--- a/apps/web/src/wasm/index.ts
+++ b/apps/web/src/wasm/index.ts
@@ -1 +1 @@
-export * from "./ticks";
+export * from "./media-time";
diff --git a/apps/web/src/wasm/media-time.ts b/apps/web/src/wasm/media-time.ts
new file mode 100644
index 00000000..0ec23cf3
--- /dev/null
+++ b/apps/web/src/wasm/media-time.ts
@@ -0,0 +1,232 @@
+import {
+ lastFrameTime as _lastFrameTime,
+ parseTimecode as _parseTimecode,
+ roundToFrame as _roundToFrame,
+ snappedSeekTime as _snappedSeekTime,
+ TICKS_PER_SECOND as _TICKS_PER_SECOND,
+ mediaTimeFromSeconds as _mediaTimeFromSeconds,
+ mediaTimeToSeconds as _mediaTimeToSeconds,
+ type FrameRate,
+ type TimeCodeFormat,
+} from "opencut-wasm";
+
+/**
+ * Integer-tick time. Mirrors `MediaTime(i64)` in `rust/crates/time/src/media_time.rs`.
+ *
+ * `opencut-wasm` exposes `MediaTime` as a bare `number` alias because tsify
+ * collapses tuple structs. The brand here is the TS-side discipline that
+ * recovers the invariant: a `MediaTime` is an integer count of ticks, and the
+ * only legal way to construct one from a fractional `number` is `roundMediaTime`
+ * (or `mediaTimeFromSeconds`, which rounds inside the wasm boundary).
+ *
+ * Reading is free — `MediaTime` is assignable to `number`. Writing is gated —
+ * a bare `number` is not assignable to `MediaTime`.
+ */
+export type MediaTime = number & { readonly __mediaTime: unique symbol };
+
+export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
+
+function isMediaTime(value: number): value is MediaTime {
+ return Number.isInteger(value);
+}
+
+function requireMediaTime({
+ value,
+ context,
+}: {
+ value: number;
+ context: string;
+}): MediaTime {
+ if (!isMediaTime(value)) {
+ throw new Error(`${context}: expected an integer tick count, got ${value}`);
+ }
+ return value;
+}
+
+export const ZERO_MEDIA_TIME = requireMediaTime({
+ value: 0,
+ context: "ZERO_MEDIA_TIME",
+});
+
+/**
+ * Construct a `MediaTime` from a known-integer tick count. Use `roundMediaTime`
+ * when the input may be fractional.
+ */
+export function mediaTime({ ticks }: { ticks: number }): MediaTime {
+ return requireMediaTime({
+ value: ticks,
+ context: "mediaTime()",
+ });
+}
+
+/**
+ * Project a fractional value onto the integer-tick lattice.
+ *
+ * Rounds half away from zero (`-1.5 → -2`, `1.5 → 2`) and normalises `-0` to
+ * `0`. The away-from-zero rule matches Rust's `.round()` and avoids the
+ * `Math.round(-0.5) === -0` quirk that propagates `-0` into stored data.
+ */
+export function roundMediaTime({ time }: { time: number }): MediaTime {
+ const roundedMagnitude = Math.round(Math.abs(time));
+ if (roundedMagnitude === 0) {
+ return ZERO_MEDIA_TIME;
+ }
+ return requireMediaTime({
+ value: time < 0 ? -roundedMagnitude : roundedMagnitude,
+ context: "roundMediaTime()",
+ });
+}
+
+export function mediaTimeFromSeconds({
+ seconds,
+}: {
+ seconds: number;
+}): MediaTime {
+ const result = _mediaTimeFromSeconds({ seconds });
+ if (result === undefined) {
+ throw new Error(
+ `mediaTimeFromSeconds: rust returned undefined for seconds=${seconds}`,
+ );
+ }
+ return requireMediaTime({
+ value: result,
+ context: "mediaTimeFromSeconds()",
+ });
+}
+
+export function mediaTimeToSeconds({ time }: { time: MediaTime }): number {
+ return _mediaTimeToSeconds({ time });
+}
+
+/**
+ * Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too.
+ */
+export function addMediaTime({
+ a,
+ b,
+}: {
+ a: MediaTime;
+ b: MediaTime;
+}): MediaTime {
+ return requireMediaTime({
+ value: a + b,
+ context: "addMediaTime()",
+ });
+}
+
+export function subMediaTime({
+ a,
+ b,
+}: {
+ a: MediaTime;
+ b: MediaTime;
+}): MediaTime {
+ return requireMediaTime({
+ value: a - b,
+ context: "subMediaTime()",
+ });
+}
+
+export function maxMediaTime({
+ a,
+ b,
+}: {
+ a: MediaTime;
+ b: MediaTime;
+}): MediaTime {
+ return a > b ? a : b;
+}
+
+export function minMediaTime({
+ a,
+ b,
+}: {
+ a: MediaTime;
+ b: MediaTime;
+}): MediaTime {
+ return a < b ? a : b;
+}
+
+export function clampMediaTime({
+ time,
+ min,
+ max,
+}: {
+ time: MediaTime;
+ min: MediaTime;
+ max: MediaTime;
+}): MediaTime {
+ if (time < min) return min;
+ if (time > max) return max;
+ return time;
+}
+
+export function roundFrameTime({
+ time,
+ fps,
+}: {
+ time: MediaTime;
+ fps: FrameRate;
+}): MediaTime {
+ return requireMediaTime({
+ value: _roundToFrame({ time, rate: fps }) ?? time,
+ context: "roundFrameTime()",
+ });
+}
+
+export function roundFrameTicks({
+ ticks,
+ fps,
+}: {
+ ticks: number;
+ fps: FrameRate;
+}): number {
+ return _roundToFrame({ time: ticks, rate: fps }) ?? ticks;
+}
+
+export function snapSeekMediaTime({
+ time,
+ duration,
+ fps,
+}: {
+ time: MediaTime;
+ duration: MediaTime;
+ fps: FrameRate;
+}): MediaTime {
+ return requireMediaTime({
+ value: _snappedSeekTime({ time, duration, rate: fps }) ?? time,
+ context: "snapSeekMediaTime()",
+ });
+}
+
+export function lastFrameMediaTime({
+ duration,
+ fps,
+}: {
+ duration: MediaTime;
+ fps: FrameRate;
+}): MediaTime {
+ return requireMediaTime({
+ value: _lastFrameTime({ duration, rate: fps }) ?? duration,
+ context: "lastFrameMediaTime()",
+ });
+}
+
+export function parseMediaTimecode({
+ timeCode,
+ format,
+ fps,
+}: {
+ timeCode: string;
+ format: TimeCodeFormat;
+ fps: FrameRate;
+}): MediaTime | null {
+ const parsedTime = _parseTimecode({ timeCode, format, rate: fps });
+ if (parsedTime == null) {
+ return null;
+ }
+ return requireMediaTime({
+ value: parsedTime,
+ context: "parseMediaTimecode()",
+ });
+}
diff --git a/apps/web/src/wasm/ticks.ts b/apps/web/src/wasm/ticks.ts
deleted file mode 100644
index ababe6b3..00000000
--- a/apps/web/src/wasm/ticks.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
-
-export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 3ac10911..3573338a 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -1,39 +1,39 @@
-{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./src/*"],
- "content-collections": ["./.content-collections/generated"]
- },
- "forceConsistentCasingInFileNames": true
- },
- "include": [
- "**/*.ts",
- "**/*.tsx",
- "apps/web/.next/types/**/*.ts",
- "next-env.d.ts",
- ".next/types/**/*.ts",
- "src/types/**/*.d.ts",
- "../../use-frame-cache.ts",
- "src/stores/timeline-store.ts",
- ".next/dev/types/**/*.ts"
- ],
- "exclude": ["node_modules"]
-}
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"],
+ "content-collections": ["./.content-collections/generated"]
+ },
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ "apps/web/.next/types/**/*.ts",
+ "next-env.d.ts",
+ ".next/types/**/*.ts",
+ "src/types/**/*.d.ts",
+ "../../use-frame-cache.ts",
+ "src/stores/timeline-store.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/bun.lock b/bun.lock
index cb36d56e..455f42ca 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,19 +1,30 @@
{
"lockfileVersion": 1,
- "configVersion": 0,
+ "configVersion": 1,
"workspaces": {
"": {
"name": "opencut",
"dependencies": {
- "@biomejs/biome": "^2.4.8",
+ "opencut": ".",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"better-auth": "^1.4.15",
"next": "^16.1.3",
+ "opencut-wasm": "^0.2.10",
},
"devDependencies": {
+ "@eslint/js": "^9.39.4",
+ "@next/eslint-plugin-next": "^16.2.4",
+ "eslint": "^9.39.4",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "globals": "^17.5.0",
+ "prettier": "^3.8.3",
"turbo": "^2.8.20",
"typescript": "^6.0.2",
+ "typescript-eslint": "^8.59.0",
},
},
"apps/web": {
@@ -54,7 +65,7 @@
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
- "opencut-wasm": "^0.2.8",
+ "opencut-wasm": "^0.2.10",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.3",
@@ -153,63 +164,63 @@
"@aws-sdk/client-sqs": ["@aws-sdk/client-sqs@3.984.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.6", "@aws-sdk/credential-provider-node": "^3.972.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-sdk-sqs": "^3.972.5", "@aws-sdk/middleware-user-agent": "^3.972.6", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.984.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.4", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/md5-js": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-TDvHpOUWlpanc3xQ5Xw0y8L2hoojBFCCSmXQ/6rKqGOf1ScX3dMA+K9aF0Zp0iwjhSh4VvsHD42esl8XwQZDjA=="],
- "@aws-sdk/core": ["@aws-sdk/core@3.973.24", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw=="],
+ "@aws-sdk/core": ["@aws-sdk/core@3.974.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.19", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-lMPlYlYfQdNZhlkJgnkmESwrY+hNh3PljmZ+37oAqLNdJ6rnILAwFSyc6B3bJeDOtMORNnMQIej0aTRuOlDyhQ=="],
- "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="],
+ "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.7", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg=="],
- "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-cXp0VTDWT76p3hyK5D51yIKEfpf6/zsUvMfaB8CkyqadJxMQ8SbEeVroregmDlZbtG31wkj9ei0WnftmieggLg=="],
+ "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-X/yGB73LmDW/6MdDJGCDzZBUXnM3ys4vs9l+5ZTJmiEswDdP1OjeoAFlFjVGS9o4KB2wZWQ9KOfdVNSSK6Ep3w=="],
- "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-h694K7+tRuepSRJr09wTvQfaEnjzsKZ5s7fbESrVds02GT/QzViJ94/HCNwM7bUfFxqpPXHxulZfL6Cou0dwPg=="],
+ "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.33", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" } }, "sha512-c0ZF+lwoWVvX5iCaGKL5T/4DnIw88CGqxA0BcBs3U86mIp5EZYPVg+KSPkMXOyokmADvNewiMUfSG2uFwjRp0g=="],
- "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/credential-provider-env": "^3.972.22", "@aws-sdk/credential-provider-http": "^3.972.24", "@aws-sdk/credential-provider-login": "^3.972.24", "@aws-sdk/credential-provider-process": "^3.972.22", "@aws-sdk/credential-provider-sso": "^3.972.24", "@aws-sdk/credential-provider-web-identity": "^3.972.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-O46fFmv0RDFWiWEA9/e6oW92BnsyAXuEgTTasxHligjn2RCr9L/DK773m/NoFaL3ZdNAUz8WxgxunleMnHAkeQ=="],
+ "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.35", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/credential-provider-env": "^3.972.31", "@aws-sdk/credential-provider-http": "^3.972.33", "@aws-sdk/credential-provider-login": "^3.972.35", "@aws-sdk/credential-provider-process": "^3.972.31", "@aws-sdk/credential-provider-sso": "^3.972.35", "@aws-sdk/credential-provider-web-identity": "^3.972.35", "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-jsU4u/cRkKFLKQS0k918FQ27fzXLG5ENiLWQMYE6581zLeI2hWh04ptlrvZMB3wJT/5d+vSzJk74X1CMFr4y8Q=="],
- "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-sIk8oa6AzDoUhxsR11svZESqvzGuXesw62Rl2oW6wguZx8i9cdGCvkFg+h5K7iucUZP8wyWibUbJMc+J66cu5g=="],
+ "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.35", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-5oa3j0cA50jPqgNhZ9XdJVopuzUf1klRb28/2MfLYWWiPi9DRVvbrBWT+DidbHTT36520VuXZJahQwR+YgSjrg=="],
- "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.25", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.22", "@aws-sdk/credential-provider-http": "^3.972.24", "@aws-sdk/credential-provider-ini": "^3.972.24", "@aws-sdk/credential-provider-process": "^3.972.22", "@aws-sdk/credential-provider-sso": "^3.972.24", "@aws-sdk/credential-provider-web-identity": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-m7dR0Dsva2P+VUpL+VkC0WwiDby5pgmWXkRVDB5rlwv0jXJrQJf7YMtCoM8Wjk0H9jPeCYOxOXXcIgp/qp5Alg=="],
+ "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.36", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.31", "@aws-sdk/credential-provider-http": "^3.972.33", "@aws-sdk/credential-provider-ini": "^3.972.35", "@aws-sdk/credential-provider-process": "^3.972.31", "@aws-sdk/credential-provider-sso": "^3.972.35", "@aws-sdk/credential-provider-web-identity": "^3.972.35", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-4nT2T8Z7vH8KE9EdjEsuIlHpZSlcaK2PrKbQBjuUGU46BCCzF3WvP0u0Uiosni3Ykmmn4rWLVawoOCLotUtCbg=="],
- "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Os32s8/4gTZjBk5BtoS/cuTILaj+K72d0dVG7TCJX/fC4598cxwLDmf1AEHEpER5oL3K//yETjvFaz0V8oO5Xw=="],
+ "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-eKeT4MXumpBJsrDLCYcSzIkFPVTFn/es7It2oogp2OhU/ic7P/+xzFpQx9ZhwtXS57Mc5S42BPWi7lHmvs/nYg=="],
- "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/token-providers": "3.1015.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PaFv7snEfypU2yXkpvfyWgddEbDLtgVe51wdZlinhc2doubBjUzJZZpgwuF2Jenl1FBydMhNpMjD6SBUM3qdSA=="],
+ "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.35", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/token-providers": "3.1036.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-bCuBdfnj0KGDMdLp6utMTLiJcFN2ek9EgZinxQZZSc3FxjJ/HSqeqab2cjbnoNfy8RM6suDCsRkmVY1izp9I+A=="],
- "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-J6H4R1nvr3uBTqD/EeIPAskrBtET4WFfNhpFySr2xW7bVZOXpQfPjrLSIx65jcNjBmLXzWq8QFLdVoGxiGG/SA=="],
+ "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.35", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-swW6Bwvl8lanyEMtZOWE/oR6yqcRQH4HTQZUVsnDVgoXvRjRywpYpLv2BWwjUFyjPrqsdX6FeTkf4tMSe/qFTQ=="],
- "@aws-sdk/dynamodb-codec": ["@aws-sdk/dynamodb-codec@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@smithy/core": "^3.23.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-AfkNIk19MOeGXfRyznRMZNOTUt79HU2pl7GP606oUIGELmS495xfvGDGp9nGYbOPxWhXfM7/s51V8+lvmCEwVg=="],
+ "@aws-sdk/dynamodb-codec": ["@aws-sdk/dynamodb-codec@3.973.5", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@smithy/core": "^3.23.17", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-SNF2VCwK2MA9P3jmz/DW498BK0SkGMyLBeAe+ZJy2fVoEOHq0b/ZdRNw4fQMuq07gdNxLErY9RnXUo6Rdu2QIA=="],
- "@aws-sdk/endpoint-cache": ["@aws-sdk/endpoint-cache@3.972.4", "", { "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" } }, "sha512-GdASDnWanLnHxKK0hqV97xz23QmfA/C8yGe0PiuEmWiHSe+x+x+mFEj4sXqx9IbfyPncWz8f4EhNwBSG9cgYCg=="],
+ "@aws-sdk/endpoint-cache": ["@aws-sdk/endpoint-cache@3.972.5", "", { "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" } }, "sha512-itVdge0NozgtgmtbZ25FVwWU3vGlE7x7feE/aOEJNkQfEpbkrF8Rj1QmnK+2blFfYE1xWt/iU+6/jUp/pv1+MA=="],
- "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="],
+ "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA=="],
- "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.972.8", "", { "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.4", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-S0oXx1QbSpMDBMJn4P0hOxW8ieGAdRT+G9NbL+ESWkkoCGf9D++fKYD2fyBGtIy88OrP7wgECpXgGLAcGpIj0A=="],
+ "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.972.11", "", { "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.5", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-vXARCZVFQHdsd6qPPZyC/hh+5x2XsCYKqUQDCqnUlpGpChMpDojOOacQWdLJ+FFXKN8X3cmLOGrtgx/zysCKqQ=="],
- "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="],
+ "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ=="],
- "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.4", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.24", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fhCbZXPAyy8btnNbnBlR7Cc1nD54cETSvGn2wey71ehsM89AKPO8Dpco9DBAAgvrUdLrdHQepBXcyX4vxC5OwA=="],
+ "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.5", "@aws-sdk/crc64-nvme": "^3.972.7", "@aws-sdk/types": "^3.973.8", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-b6QUe2hQX9XsnCzp6mtzVaERhganDKeb8lmGL6pVhr7rRVH9S9keDFW7uKytuuqmcY5943FixoGqn/QL+sbUBA=="],
- "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="],
+ "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg=="],
- "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="],
+ "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ=="],
- "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="],
+ "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ=="],
- "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="],
+ "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ=="],
- "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4sXxVC/enYgMkZefNMOzU6C6KtAXEvwVJLgNcUx1dvROH6GvKB5Sm2RGnGzTp0/PwkibIyMw4kOzF8tbLfaBAQ=="],
+ "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/UL96JKjsjdodcRRMKl99tLQvK6Oi9ptLC9iU1yiTF/ruaDX0mtBBtnLNZDxIZRJOCVOtB49ed1YaTadqygk8Q=="],
- "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-LnzPRRoDXGtlFV2G1p2rsY6fRKrbf6Pvvc21KliSLw3+NmQca2+Aa1QIMRbpQvZYedsSqkGYwxe+qvXwQ2uxDw=="],
+ "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.22", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-DtR3mEiOUJcnEX/QuXmvbJto6xvQzp2ftnHb29c0aQYdmmzbKf0gsu9ovx1i/yy4ZR6m0rttTucS0iiP32dlGA=="],
- "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="],
+ "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw=="],
- "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-QxiMPofvOt8SwSynTOmuZfvvPM1S9QfkESBxB22NMHTRXCJhR5BygLl8IXfC4jELiisQgwsgUby21GtXfX3f/g=="],
+ "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.35", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-retry": "^4.3.4", "tslib": "^2.6.2" } }, "sha512-hOFWNOjVmOocpRlrU04nYxjMOeoe0Obu5AXEuhB8zblMCPl3cG1hdluQCZERRKFyhMQjwZnDbhSHjoMUjetFGw=="],
- "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.14", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.24", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.11", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fSESKvh1VbfjtV3QMnRkCPZWkUbQof6T/DOpiLp33yP2wA+rbwwnZeG3XT3Ekljgw2I8X4XaQPnw+zSR8yxJ5Q=="],
+ "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.3", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.5", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/signature-v4-multi-region": "^3.996.22", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-SivE6GP228IVgfsrr2c/vqTg95X0Qj39Yw4uIrcddpkUzIltNMoNOR62leHOLhODfjv9K8X2mPTwS69A5kT0nQ=="],
- "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="],
+ "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.13", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/config-resolver": "^4.4.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.984.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.6", "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-TaWbfYCwnuOSvDSrgs7QgoaoXse49E7LzUkVOUhoezwB7bkmhp+iojADm7UepCEu4021SquD7NG1xA+WCvmldA=="],
- "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1015.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-3OSD4y110nisRhHzFOjoEeHU4GQL4KpzkX9PxzWaiZe0Yg2+thZKM0Pn9DjYwezH5JYfh/K++xK/SE0IHGrmCQ=="],
+ "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1036.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-aNSJ6jjDYayxN9ZA1JpycVScX93Lx03kKZ1EXt3DGOTahcWVLJj3oLAlop0xKP+vP2Ga2t49p1tEaMkTbCCaZA=="],
- "@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="],
+ "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
@@ -217,57 +228,81 @@
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
- "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="],
+ "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g=="],
- "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.11", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-1qdXbXo2s5MMLpUvw00284LsbhtlQ4ul7Zzdn5n+7p4WVgCMLqhxImpHIrjSoc72E/fyc4Wq8dLtUld2Gsh+lA=="],
+ "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.21", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Av4UHTcAWgdvbN0IP9pbtf4Qa1+6LtJqQdZWj5pLn5J67w0pnJJAZZ+7JPPcj2KN3378zD2JDM9DwJKEyvyMTQ=="],
- "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="],
+ "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.19", "", { "dependencies": { "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.1", "tslib": "^2.6.2" } }, "sha512-Cw8IOMdBUEIl8ZlhRC3Dc/E64D5B5/8JhV6vhPLiPfJwcRC84S6F8aBOIi/N4vR9ZyA4I5Cc0Ateb/9EHaJXeQ=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
- "@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="],
+ "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
- "@better-auth/core": ["@better-auth/core@1.4.15", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-uAvq8YA7SaS7v+TrvH/Kwt7LAJihzUqB3FX8VweDsqu3gn5t51M+Bve+V1vVWR9qBAtC6cN68V6b+scxZxDY4A=="],
+ "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
- "@better-auth/telemetry": ["@better-auth/telemetry@1.4.15", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.15" } }, "sha512-7NW/2PS4RN85rv+ozpAezP/kSLPZeWkxqcA6RA/CFXqWp2YR2e5q5E6Hym1qBgVBkoAQa3lWFdX3b+jEs+vvrQ=="],
+ "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
- "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
+ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
- "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
+
+ "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
+
+ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
+
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
+
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
+
+ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
+
+ "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
+
+ "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
- "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="],
+ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="],
+ "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="],
+ "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="],
+ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="],
+ "@better-auth/core": ["@better-auth/core@1.6.9", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w=="],
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="],
+ "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw=="],
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="],
+ "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "kysely": "^0.28.14" }, "optionalPeers": ["kysely"] }, "sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw=="],
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="],
+ "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0" } }, "sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ=="],
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="],
+ "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-h+AiRJ/TsBSi+ZDjySASBpbJ/9QCXBre34PSKgCz7QmTHrFM9Cg2EM4AM7LjR5lPXipEE+2rWPBc9wfnUBjhcw=="],
+
+ "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q=="],
+
+ "@better-auth/telemetry": ["@better-auth/telemetry@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A=="],
+
+ "@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="],
+
+ "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
- "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="],
+ "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
- "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260317.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g=="],
+ "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="],
- "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260317.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg=="],
+ "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="],
- "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260317.1", "", { "os": "linux", "cpu": "x64" }, "sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug=="],
+ "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="],
- "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260317.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw=="],
+ "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="],
- "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260317.1", "", { "os": "win32", "cpu": "x64" }, "sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ=="],
+ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="],
- "@content-collections/core": ["@content-collections/core@0.14.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "camelcase": "^8.0.0", "chokidar": "^4.0.3", "esbuild": "^0.25.11", "gray-matter": "^4.0.3", "p-limit": "^6.1.0", "picomatch": "^4.0.2", "pluralize": "^8.0.0", "serialize-javascript": "^6.0.2", "tinyglobby": "^0.2.5", "yaml": "^2.4.5" }, "peerDependencies": { "typescript": "^5.0.2" } }, "sha512-mfviKnONzVcr1D3uasZFEk0E9N4a7liCra06imQrmKF07nGDuvrRKUFoUwUc2IGrW3EYqKF/KV06zfJRzNe3sg=="],
+ "@content-collections/core": ["@content-collections/core@0.14.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "camelcase": "^8.0.0", "chokidar": "^4.0.3", "esbuild": "^0.25.11", "gray-matter": "^4.0.3", "p-limit": "^6.1.0", "picomatch": "^4.0.2", "pluralize": "^8.0.0", "serialize-javascript": "^7.0.3", "tinyglobby": "^0.2.5", "yaml": "^2.4.5" }, "peerDependencies": { "typescript": "^5.0.2" } }, "sha512-nbgfRswxudlKDBEO2AXVGGfsFMlenV2iVjNDddQGWD0qk3lBDkTRJy0AqdBvBOccB8/cj95nJyXEVocRRNyUcA=="],
"@content-collections/integrations": ["@content-collections/integrations@0.5.0", "", { "peerDependencies": { "@content-collections/core": "0.x" } }, "sha512-1en7r518sct0Y8CQ5IsuuBN4uAmtNLaWuxmseW43OxeXyj43Uu2aPBfbopjL4b5xH8WZBdDrrPmikgOl42U46A=="],
@@ -279,9 +314,9 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
- "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="],
+ "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="],
- "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
+ "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
@@ -339,13 +374,31 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
- "@floating-ui/core": ["@floating-ui/core@1.7.2", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw=="],
+ "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
+
+ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
+
+ "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
+
+ "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
- "@floating-ui/dom": ["@floating-ui/dom@1.7.2", "", { "dependencies": { "@floating-ui/core": "^1.7.2", "@floating-ui/utils": "^0.2.10" } }, "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA=="],
+ "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
- "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.4", "", { "dependencies": { "@floating-ui/dom": "^1.7.2" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw=="],
+ "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
- "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
+ "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
+
+ "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
+
+ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
+
+ "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
+
+ "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
+
+ "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
+
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@hello-pangea/dnd": ["@hello-pangea/dnd@18.0.1", "", { "dependencies": { "@babel/runtime": "^7.26.7", "css-box-model": "^1.2.1", "raf-schd": "^4.0.3", "react-redux": "^9.2.0", "redux": "^5.0.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ=="],
@@ -353,11 +406,21 @@
"@hugeicons/react": ["@hugeicons/react@1.1.6", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-c2LhXJMAW5wN1pC/smBXG0YPqUON6ceR/ZdXHCjEI9KvB+hjtqYjmzIxok5hAQOeXGz0WtORgCQMzqewFKAZwg=="],
- "@huggingface/jinja": ["@huggingface/jinja@0.5.3", "", {}, "sha512-asqfZ4GQS0hD876Uw4qiUb7Tr/V5Q+JZuo2L+BtdrD4U40QU58nIRq3ZSgAzJgT874VLjhGVacaYfrdpXtEvtA=="],
+ "@huggingface/jinja": ["@huggingface/jinja@0.5.8", "", {}, "sha512-ZdElB7DPS7QQS8ZnFc5RPPtkg+eN11z8AmIZWAyes6pSbwXqiFB/POVevvm01begdSX1ho9Gxln/F6qlQMsuaA=="],
"@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="],
- "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
+ "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
+
+ "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
+
+ "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
+
+ "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
+
+ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
+
+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@@ -411,7 +474,7 @@
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
- "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -421,55 +484,59 @@
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
- "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="],
- "@napi-rs/canvas": ["@napi-rs/canvas@0.1.92", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.92", "@napi-rs/canvas-darwin-arm64": "0.1.92", "@napi-rs/canvas-darwin-x64": "0.1.92", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.92", "@napi-rs/canvas-linux-arm64-gnu": "0.1.92", "@napi-rs/canvas-linux-arm64-musl": "0.1.92", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.92", "@napi-rs/canvas-linux-x64-gnu": "0.1.92", "@napi-rs/canvas-linux-x64-musl": "0.1.92", "@napi-rs/canvas-win32-arm64-msvc": "0.1.92", "@napi-rs/canvas-win32-x64-msvc": "0.1.92" } }, "sha512-q7ZaUCJkEU5BeOdE7fBx1XWRd2T5Ady65nxq4brMf5L4cE1VV/ACq5w9Z5b/IVJs8CwSSIwc30nlthH0gFo4Ig=="],
+ "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.100", "", { "os": "android", "cpu": "arm64" }, "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ=="],
- "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.92", "", { "os": "android", "cpu": "arm64" }, "sha512-rDOtq53ujfOuevD5taxAuIFALuf1QsQWZe1yS/N4MtT+tNiDBEdjufvQRPWZ11FubL2uwgP8ApYU3YOaNu1ZsQ=="],
+ "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.100", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A=="],
- "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.92", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4PT6GRGCr7yMRehp42x0LJb1V0IEy1cDZDDayv7eKbFUIGbPFkV7CRC9Bee5MPkjg1EB4ZPXXUyy3gjQm7mR8Q=="],
+ "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.100", "", { "os": "darwin", "cpu": "x64" }, "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw=="],
- "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.92", "", { "os": "darwin", "cpu": "x64" }, "sha512-5e/3ZapP7CqPtDcZPtmowCsjoyQwuNMMD7c0GKPtZQ8pgQhLkeq/3fmk0HqNSD1i227FyJN/9pDrhw/UMTkaWA=="],
+ "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.100", "", { "os": "linux", "cpu": "arm" }, "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA=="],
- "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.92", "", { "os": "linux", "cpu": "arm" }, "sha512-j6KaLL9iir68lwpzzY+aBGag1PZp3+gJE2mQ3ar4VJVmyLRVOh+1qsdNK1gfWoAVy5w6U7OEYFrLzN2vOFUSng=="],
+ "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw=="],
- "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.92", "", { "os": "linux", "cpu": "arm64" }, "sha512-s3NlnJMHOSotUYVoTCoC1OcomaChFdKmZg0VsHFeIkeHbwX0uPHP4eCX1irjSfMykyvsGHTQDfBAtGYuqxCxhQ=="],
+ "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ=="],
- "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.92", "", { "os": "linux", "cpu": "arm64" }, "sha512-xV0GQnukYq5qY+ebkAwHjnP2OrSGBxS3vSi1zQNQj0bkXU6Ou+Tw7JjCM7pZcQ28MUyEBS1yKfo7rc7ip2IPFQ=="],
+ "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.100", "", { "os": "linux", "cpu": "none" }, "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw=="],
- "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.92", "", { "os": "linux", "cpu": "none" }, "sha512-+GKvIFbQ74eB/TopEdH6XIXcvOGcuKvCITLGXy7WLJAyNp3Kdn1ncjxg91ihatBaPR+t63QOE99yHuIWn3UQ9w=="],
+ "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg=="],
- "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.92", "", { "os": "linux", "cpu": "x64" }, "sha512-tFd6MwbEhZ1g64iVY2asV+dOJC+GT3Yd6UH4G3Hp0/VHQ6qikB+nvXEULskFYZ0+wFqlGPtXjG1Jmv7sJy+3Ww=="],
+ "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA=="],
- "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.92", "", { "os": "linux", "cpu": "x64" }, "sha512-uSuqeSveB/ZGd72VfNbHCSXO9sArpZTvznMVsb42nqPP7gBGEH6NJQ0+hmF+w24unEmxBhPYakP/Wiosm16KkA=="],
+ "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.100", "", { "os": "win32", "cpu": "arm64" }, "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw=="],
- "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.92", "", { "os": "win32", "cpu": "arm64" }, "sha512-20SK5AU/OUNz9ZuoAPj5ekWai45EIBDh/XsdrVZ8le/pJVlhjFU3olbumSQUXRFn7lBRS+qwM8kA//uLaDx6iQ=="],
+ "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="],
- "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.92", "", { "os": "win32", "cpu": "x64" }, "sha512-KEhyZLzq1MXCNlXybz4k25MJmHFp+uK1SIb8yJB0xfrQjz5aogAMhyseSzewo+XxAq3OAOdyKvfHGNzT3w1RPg=="],
+ "@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="],
- "@next/env": ["@next/env@16.1.3", "", {}, "sha512-BLP14oBOvZWXgfdJf9ao+VD8O30uE+x7PaV++QtACLX329WcRSJRO5YJ+Bcvu0Q+c/lei41TjSiFf6pXqnpbQA=="],
+ "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.4", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA=="],
- "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CpOD3lmig6VflihVoGxiR/l5Jkjfi4uLaOR4ziriMv0YMDoF6cclI+p5t2nstM8TmaFiY6PCTBgRWB57/+LiBA=="],
+ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="],
- "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-aF4us2JXh0zn3hNxvL1Bx3BOuh8Lcw3p3Xnurlvca/iptrDH1BrpObwkw9WZra7L7/0qB9kjlREq3hN/4x4x+Q=="],
+ "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ=="],
- "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8VRkcpcfBtYvhGgXAF7U3MBx6+G1lACM1XCo1JyaUr4KmAkTNP8Dv2wdMq7BI+jqRBw3zQE7c57+lmp7jCFfKA=="],
+ "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ=="],
- "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-UbFx69E2UP7MhzogJRMFvV9KdEn4sLGPicClwgqnLht2TEi204B71HuVfps3ymGAh0c44QRAF+ZmvZZhLLmhNg=="],
+ "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg=="],
- "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SzGTfTjR5e9T+sZh5zXqG/oeRQufExxBF6MssXS7HPeZFE98JDhCRZXpSyCfWrWrYrzmnw/RVhlP2AxQm+wkRQ=="],
+ "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ=="],
- "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-HlrDpj0v+JBIvQex1mXHq93Mht5qQmfyci+ZNwGClnAQldSfxI6h0Vupte1dSR4ueNv4q7qp5kTnmLOBIQnGow=="],
+ "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA=="],
- "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3gFCp83/LSduZMSIa+lBREP7+5e7FxpdBoc9QrCdmp+dapmTK9I+SLpY60Z39GDmTXSZA4huGg9WwmYbr6+WRw=="],
+ "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow=="],
- "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-1SZVfFT8zmMB+Oblrh5OKDvUo5mYQOkX2We6VGzpg7JUVZlqe4DYOFGKYZKTweSx1gbMixyO1jnFT4thU+nNHQ=="],
+ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="],
- "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
+ "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
- "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
+ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
+
+ "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
"@node-minify/core": ["@node-minify/core@8.0.6", "", { "dependencies": { "@node-minify/utils": "8.0.6", "glob": "9.3.5", "mkdirp": "1.0.4" } }, "sha512-/vxN46ieWDLU67CmgbArEvOb41zlYFOkOtr9QW9CnTrBLuTyGgkyNWC2y5+khvRw3Br58p2B5ZVSx/PxCTru6g=="],
@@ -477,11 +544,19 @@
"@node-minify/utils": ["@node-minify/utils@8.0.6", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-csY4qcR7jUwiZmkreNTJhcypQfts2aY2CK+a+rXgXUImZiZiySh0FvwHjRnlqWKvg+y6ae9lHFzDRjBTmqlTIQ=="],
+ "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
+
+ "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
+
+ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
+
"@opencut/web": ["@opencut/web@workspace:apps/web"],
- "@opennextjs/aws": ["@opennextjs/aws@3.9.16", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q=="],
+ "@opennextjs/aws": ["@opennextjs/aws@3.10.4", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.15 <16 || >=16.2.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-xVmWHGdptJgVhQivuoeAYqsWpIgGoDEeZJC6AYMgvQYisDicGuS7gh10Z8MEmrgsJxNGdZ0arCCDieGxM88afw=="],
- "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.18.0", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.16", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-JM236YHnKzroFAZqst1t28ZGOShvnkVUDtjrp7TJ/W2P3RLo4b6npJ8VEXOn6frs6lsUfR5rvsKYLYb7h1GIJQ=="],
+ "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.4", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.10.4", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.15 <16 || >=16.2.3", "wrangler": "^4.84.1" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-sRJmLFz6DqGzoToSTPyyQhxozXGgF/YsnHpgiCe2XTugfZZnfetMoI4BolFP65XAzIV3tdSSzCb+etK5s8UEUg=="],
+
+ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
@@ -631,75 +706,73 @@
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
- "@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
-
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
- "@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="],
+ "@smithy/config-resolver": ["@smithy/config-resolver@4.4.17", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ=="],
- "@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="],
+ "@smithy/core": ["@smithy/core@3.23.17", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ=="],
- "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
+ "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.14", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg=="],
- "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
+ "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
- "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
+ "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.14", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ=="],
- "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
+ "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA=="],
- "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
+ "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.14", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw=="],
- "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
+ "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.14", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg=="],
- "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
+ "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.17", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw=="],
- "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
+ "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.15", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA=="],
- "@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
+ "@smithy/hash-node": ["@smithy/hash-node@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g=="],
- "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
+ "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ=="],
- "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
+ "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
- "@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
+ "@smithy/md5-js": ["@smithy/md5-js@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA=="],
- "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
+ "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.14", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw=="],
- "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
+ "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.32", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/middleware-serde": "^4.2.20", "@smithy/node-config-provider": "^4.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q=="],
- "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="],
+ "@smithy/middleware-retry": ["@smithy/middleware-retry@4.5.5", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/service-error-classification": "^4.3.0", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.4", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-wnYOpB5vATFKWrY2Z9Alb0KhjZI6AbzU6Fbz3Hq2GnURdRYWB4q+qWivQtSTwXcmWUA3MZ6krfwL6Cq5MAbxsA=="],
- "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="],
+ "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.20", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ=="],
- "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
+ "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA=="],
- "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
+ "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.14", "", { "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg=="],
- "@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="],
+ "@smithy/node-http-handler": ["@smithy/node-http-handler@4.6.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg=="],
- "@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
+ "@smithy/property-provider": ["@smithy/property-provider@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ=="],
- "@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
+ "@smithy/protocol-http": ["@smithy/protocol-http@5.3.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ=="],
- "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
+ "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A=="],
- "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
+ "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw=="],
- "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
+ "@smithy/service-error-classification": ["@smithy/service-error-classification@4.3.0", "", { "dependencies": { "@smithy/types": "^4.14.1" } }, "sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A=="],
- "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
+ "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.9", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ=="],
- "@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
+ "@smithy/signature-v4": ["@smithy/signature-v4@5.3.14", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA=="],
- "@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="],
+ "@smithy/smithy-client": ["@smithy/smithy-client@4.12.13", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-stack": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" } }, "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA=="],
- "@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
+ "@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="],
- "@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
+ "@smithy/url-parser": ["@smithy/url-parser@4.2.14", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ=="],
"@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="],
@@ -711,25 +784,25 @@
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
- "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="],
+ "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.49", "", { "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw=="],
- "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="],
+ "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.54", "", { "dependencies": { "@smithy/config-resolver": "^4.4.17", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw=="],
- "@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
+ "@smithy/util-endpoints": ["@smithy/util-endpoints@3.4.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="],
- "@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
+ "@smithy/util-middleware": ["@smithy/util-middleware@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw=="],
- "@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
+ "@smithy/util-retry": ["@smithy/util-retry@4.3.4", "", { "dependencies": { "@smithy/service-error-classification": "^4.3.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-FY1UQQ1VFmMwiYp1GVS4MeaGD5O0blLNYK0xCRHU+mJgeoH/hSY8Ld8sJWKQ6uznkh14HveRGQJncgPyNl9J+A=="],
- "@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="],
+ "@smithy/util-stream": ["@smithy/util-stream@4.5.25", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA=="],
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
- "@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
+ "@smithy/util-waiter": ["@smithy/util-waiter@4.2.16", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-GtclrKoZ3Lt7jPQ7aTIYKfjY92OgceScftVnkTsG8e1KV8rkvZgN+ny6YSRhd9hxB8rZtwVbmln7NTvE5O3GmQ=="],
"@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
@@ -739,57 +812,57 @@
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
- "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
+ "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
- "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="],
- "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="],
- "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="],
- "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="],
- "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="],
- "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="],
- "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="],
- "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="],
- "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="],
- "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="],
- "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="],
- "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="],
- "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
- "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="],
+ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.4", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "postcss": "^8.5.6", "tailwindcss": "4.2.4" } }, "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tsconfig/node18": ["@tsconfig/node18@1.0.3", "", {}, "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ=="],
- "@turbo/darwin-64": ["@turbo/darwin-64@2.8.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ=="],
+ "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
- "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw=="],
+ "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
- "@turbo/linux-64": ["@turbo/linux-64@2.8.20", "", { "os": "linux", "cpu": "x64" }, "sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA=="],
+ "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="],
- "@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA=="],
+ "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="],
- "@turbo/windows-64": ["@turbo/windows-64@2.8.20", "", { "os": "win32", "cpu": "x64" }, "sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw=="],
+ "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="],
- "@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="],
+ "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="],
- "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
+ "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
- "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
+ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="],
@@ -801,17 +874,19 @@
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
- "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
+ "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
- "@types/pg": ["@types/pg@8.15.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg=="],
+ "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
- "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -819,13 +894,33 @@
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
+ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="],
+
+ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="],
+
+ "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="],
+
+ "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="],
+
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="],
+
+ "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg=="],
+
+ "@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="],
+
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="],
+
+ "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="],
+
+ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="],
+
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="],
- "@upstash/ratelimit": ["@upstash/ratelimit@2.0.6", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-Uak5qklMfzFN5RXltxY6IXRENu+Hgmo9iEgMPOlUs2etSQas2N+hJfbHw37OUy4vldLRXeD0OzL+YRvO2l5acg=="],
+ "@upstash/ratelimit": ["@upstash/ratelimit@2.0.8", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w=="],
- "@upstash/redis": ["@upstash/redis@1.35.4", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-WE1ZnhFyBiIjTDW13GbO6JjkiMVVjw5VsvS8ENmvvJsze/caMQ5paxVD44+U68IUVmkXcbsLSoE+VIYsHtbQEw=="],
+ "@upstash/redis": ["@upstash/redis@1.37.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
@@ -833,33 +928,63 @@
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
+ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
+ "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
+
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
- "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
+ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
+
+ "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
+
+ "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
+
"array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="],
+ "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="],
+
+ "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
+
+ "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="],
+
+ "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="],
+
+ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
+
+ "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
+
+ "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
+
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
+
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
+ "axe-core": ["axe-core@4.11.3", "", {}, "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg=="],
+
+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
- "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="],
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="],
- "better-auth": ["better-auth@1.4.15", "", { "dependencies": { "@better-auth/core": "1.4.15", "@better-auth/telemetry": "1.4.15", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.12" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-XZr4GnFPbjvf8wip8AAjTrpGNn3Sba600zT+DgsR3NNCMWCt9aD8+nuRah6BHwHWnVP1nfnby07tPmti72SRBw=="],
+ "better-auth": ["better-auth@1.6.9", "", { "dependencies": { "@better-auth/core": "1.6.9", "@better-auth/drizzle-adapter": "1.6.9", "@better-auth/kysely-adapter": "1.6.9", "@better-auth/memory-adapter": "1.6.9", "@better-auth/mongo-adapter": "1.6.9", "@better-auth/prisma-adapter": "1.6.9", "@better-auth/telemetry": "1.6.9", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.14", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA=="],
- "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="],
+ "better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="],
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
@@ -871,25 +996,33 @@
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
- "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
+ "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
+
+ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+
+ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
- "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
+ "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
+ "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
+
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
+
"camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
- "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
+ "caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
- "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
@@ -903,6 +1036,8 @@
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
+ "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
+
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -915,6 +1050,10 @@
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
+ "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
@@ -923,10 +1062,14 @@
"comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="],
- "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
+ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
+
+ "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
@@ -943,17 +1086,27 @@
"culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
+ "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
+
+ "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
+
+ "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
+
+ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
+
"date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
- "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
- "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
+ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
- "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
+ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
@@ -969,11 +1122,13 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+ "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
+
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
- "drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="],
+ "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
- "drizzle-orm": ["drizzle-orm@0.44.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-8nIiYQxOpgUicEL04YFojJmvC4DNO4KoyXsEIqN44+g6gNBr6hmVpWk3uyAt4CaTiRGDwoU+alfqNNeonLAFOQ=="],
+ "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -983,17 +1138,19 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
+ "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="],
+
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
"embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
- "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+ "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
- "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
+ "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="],
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
@@ -1001,35 +1158,65 @@
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
+ "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
+
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+ "es-iterator-helpers": ["es-iterator-helpers@1.3.2", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw=="],
+
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
+ "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="],
+
+ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
+
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
- "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
-
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
+ "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
+
+ "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
+
+ "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
+
+ "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
+
+ "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
+
+ "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
+
+ "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
+
+ "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
+
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
+ "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
+
+ "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
+
+ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
- "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
+ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
@@ -1039,18 +1226,40 @@
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
- "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
+
+ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
+
+ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
- "fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
+ "fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
+
+ "fast-xml-parser": ["fast-xml-parser@5.7.1", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA=="],
+
+ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
- "feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="],
+ "feed": ["feed@5.2.1", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-jTynzYPWs9ALjro0GW8j7sv9y7cJBeOdD4Y88kVqYy/eyusIX3g+499JiTDIlD9Ge/unebx57T4Uzo6vpYvMtA=="],
+
+ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
+
+ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
+ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
+
+ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
+
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
+ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
+
+ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
+
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
@@ -1061,7 +1270,7 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
- "framer-motion": ["framer-motion@12.23.6", "", { "dependencies": { "motion-dom": "^12.23.6", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw=="],
+ "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
@@ -1071,6 +1280,14 @@
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+ "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
+
+ "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
+
+ "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
+
+ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
@@ -1083,14 +1300,20 @@
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
- "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="],
+ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
+
+ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob": ["glob@12.0.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw=="],
+ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
+
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
+ "globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
+
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
@@ -1103,13 +1326,19 @@
"gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="],
+ "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
+
+ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
+ "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
+
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
- "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+ "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
@@ -1133,6 +1362,10 @@
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
+ "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
+
+ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
+
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
@@ -1147,73 +1380,157 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
+
+ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
- "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
+ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
+ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
+
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+ "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
+
+ "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
+
+ "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
+
+ "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
+
+ "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
+
+ "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
+
+ "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
+
+ "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
+
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
+
+ "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
+ "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
+
+ "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
+
+ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
+
+ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
+
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
+ "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
+
+ "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
+
+ "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
+
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
+ "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
+
+ "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
+
+ "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
+
+ "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
+
+ "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
+
+ "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
+
+ "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
+
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
+
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
- "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
+ "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
- "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
+ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
+
+ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+
+ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+
+ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+
+ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
+ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+
+ "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
+
+ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
- "kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="],
+ "kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
- "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
+ "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
- "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
+ "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
- "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
+ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
- "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
- "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
- "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
- "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
- "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
- "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
- "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
- "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
- "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+
+ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+
+ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
@@ -1221,7 +1538,7 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
- "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
+ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
@@ -1231,7 +1548,7 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
- "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
+ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
@@ -1241,7 +1558,7 @@
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
- "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
@@ -1249,12 +1566,14 @@
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
- "mediabunny": ["mediabunny@1.29.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-RrlKs69MxRGa/l9cMGeI4hzuTSgchOGFHk9lIAuu9EcbSdJ05gDbRsTY67dojAiyUP3ic4PExij5OqDxqSv82Q=="],
+ "mediabunny": ["mediabunny@1.41.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-cJWBHvAyRNgTbsx8Z2oHptX/PdiywwsS+GIvv2k9eioXSOzzAemBsHwf7jM6fvW3b65azvZGP9RLuO1DI8JmdA=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
+ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
+
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
@@ -1297,54 +1616,72 @@
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
- "miniflare": ["miniflare@4.20260317.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260317.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-qNL+yWAFMX6fr0pWU6Lx1vNpPobpnDSF1V8eunIckWvoIQl8y1oBjL2RJFEGY3un+l3f9gwW9dirDPP26usYJQ=="],
+ "miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="],
- "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
+ "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
- "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
+ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
- "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
+ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
- "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
+ "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="],
- "motion": ["motion@12.23.6", "", { "dependencies": { "framer-motion": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-6U55IW5i6Vut2ryKEhrZKg55490k9d6qdGXZoNSf98oQgDj5D7bqTnVJotQ6UW3AS6QfbW6KSLa7/e1gy+a07g=="],
+ "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
- "motion-dom": ["motion-dom@12.23.6", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w=="],
+ "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
- "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
+ "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
- "nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
+ "nanoid": ["nanoid@5.1.9", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw=="],
+
+ "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="],
- "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
+ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
- "next": ["next@16.1.3", "", { "dependencies": { "@next/env": "16.1.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.3", "@next/swc-darwin-x64": "16.1.3", "@next/swc-linux-arm64-gnu": "16.1.3", "@next/swc-linux-arm64-musl": "16.1.3", "@next/swc-linux-x64-gnu": "16.1.3", "@next/swc-linux-x64-musl": "16.1.3", "@next/swc-win32-arm64-msvc": "16.1.3", "@next/swc-win32-x64-msvc": "16.1.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw=="],
+ "next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
+ "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
+
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
+ "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
+
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
+ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="],
+ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
+
+ "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="],
+
+ "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="],
+
+ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
+
"obliterator": ["obliterator@1.6.1", "", {}, "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
@@ -1359,39 +1696,53 @@
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
- "opencut-wasm": ["opencut-wasm@0.2.8", "", {}, "sha512-R1cXB4HuTC5RdbGRYD0Q2n9rWQWDRB7KMwGJnbjrhnnwxBbR5XDu/3tL7hlzb/RZ7VZcaX5OGHZ1QgL6ex/2cQ=="],
+ "opencut": ["opencut@root:", {}],
+
+ "opencut-wasm": ["opencut-wasm@0.2.10", "", {}, "sha512-dy+Z9SWwpjLjgmTAMQoMMIUmdbUk9OXoWXLoacl9xT9TCrekIGSeMM0F7bJ1H3VJwwUxbGasaVibvn5AfmeZrg=="],
+
+ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+
+ "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
+ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
+ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
+
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
- "path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="],
+ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
+ "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
- "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="],
+ "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="],
- "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="],
+ "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
- "pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="],
+ "pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
- "pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="],
+ "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="],
- "pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="],
+ "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
@@ -1399,59 +1750,71 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
- "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
+
+ "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
- "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
+ "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
- "postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
+ "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
+ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+
+ "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
+
+ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
+
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
- "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
+ "protobufjs": ["protobufjs@7.5.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
- "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
+ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
+ "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
+
+ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
- "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
-
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
- "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
+ "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="],
- "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
+ "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
+
+ "react-hook-form": ["react-hook-form@7.74.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g=="],
- "react-hook-form": ["react-hook-form@7.60.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A=="],
+ "react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="],
- "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="],
+ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
- "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
+ "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
@@ -1465,6 +1828,10 @@
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
+ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
+
+ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
+
"rehype-autolink-headings": ["rehype-autolink-headings@7.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw=="],
"rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="],
@@ -1479,25 +1846,37 @@
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
+ "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
+
+ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
+
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
+
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
- "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
+
+ "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
+
+ "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
+
+ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
- "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
+ "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
- "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
@@ -1505,11 +1884,17 @@
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
- "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
+ "serialize-javascript": ["serialize-javascript@7.0.5", "", {}, "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
- "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
+ "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
+
+ "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
+
+ "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
+
+ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
@@ -1521,7 +1906,7 @@
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
- "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
+ "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
@@ -1547,8 +1932,22 @@
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+ "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
+
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+ "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
+
+ "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
+
+ "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="],
+
+ "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="],
+
+ "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="],
+
+ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
+
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1557,31 +1956,37 @@
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
- "strnum": ["strnum@2.2.2", "", {}, "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA=="],
+ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
- "style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="],
+ "strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
- "style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="],
+ "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
+
+ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
- "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
+ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
- "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
+ "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
- "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
+ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
- "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
+ "tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="],
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
- "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
+
+ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -1591,40 +1996,62 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
+
"ts-tqdm": ["ts-tqdm@0.8.6", "", {}, "sha512-3X3M1PZcHtgQbnwizL+xU8CAgbYbeLHrrDwL9xxcZZrV5J+e7loJm1XrXozHjSkl44J0Zg0SgA8rXbh83kCkcQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
- "turbo": ["turbo@2.8.20", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.20", "@turbo/darwin-arm64": "2.8.20", "@turbo/linux-64": "2.8.20", "@turbo/linux-arm64": "2.8.20", "@turbo/windows-64": "2.8.20", "@turbo/windows-arm64": "2.8.20" }, "bin": { "turbo": "bin/turbo" } }, "sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ=="],
+ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
+
+ "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="],
+
+ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
- "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
+ "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
+
+ "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
+
+ "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="],
+
+ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
+
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
+
+ "typescript-eslint": ["typescript-eslint@8.59.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw=="],
+
+ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
- "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
+ "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="],
- "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
- "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="],
+ "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
- "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
- "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="],
+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+
+ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+
"urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@@ -1633,7 +2060,7 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
- "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
+ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -1643,9 +2070,9 @@
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
- "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
+ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
- "wavesurfer.js": ["wavesurfer.js@7.10.0", "", {}, "sha512-GiyAHdorqGtUYG5fe4BfTf5lmtSLhrXoHeNlMsR80JOiOZxOrIOv9QaIR8RnqlleJ6D8R9cqvZKR9lfJcWcapg=="],
+ "wavesurfer.js": ["wavesurfer.js@7.12.6", "", {}, "sha512-zSxPgOFprtyJ31ppHQF0+E9jAmjAhi1rR36yIW6h1GOYdpRxDe6mbkYtlChqLK0Iz8ROBweiEFw2zus7tDFibA=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
@@ -1657,9 +2084,19 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
- "workerd": ["workerd@1.20260317.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260317.1", "@cloudflare/workerd-darwin-arm64": "1.20260317.1", "@cloudflare/workerd-linux-64": "1.20260317.1", "@cloudflare/workerd-linux-arm64": "1.20260317.1", "@cloudflare/workerd-windows-64": "1.20260317.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g=="],
+ "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
+
+ "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
+
+ "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
- "wrangler": ["wrangler@4.77.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260317.2", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260317.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260317.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-E2Gm69+K++BFd3QvoWjC290RPQj1vDOUotA++sNHmtKPb7EP6C8Qv+1D5Ii73tfZtyNgakpqHlh8lBBbVWTKAQ=="],
+ "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
+
+ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+
+ "workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="],
+
+ "wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
@@ -1675,7 +2112,7 @@
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
- "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
+ "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
@@ -1689,7 +2126,9 @@
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
- "zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="],
+ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
+
+ "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
@@ -1699,11 +2138,11 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
- "@aws-sdk/middleware-user-agent/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
+ "@aws-sdk/middleware-user-agent/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" } }, "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g=="],
- "@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
+ "@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.22", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.34", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-/rXhMXteD+BqhFd0nYprAgcZ/KtU+963uftPqd3tiFcFfooHZINXUGtOmo2SQjRVauCTNqIEzkwuSETdZFqTTA=="],
- "@better-auth/core/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
+ "@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" } }, "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
@@ -1713,20 +2152,24 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
- "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="],
+ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
- "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="],
+ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@node-minify/core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
- "@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
+ "@opencut/web/next": ["next@16.1.3", "", { "dependencies": { "@next/env": "16.1.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.3", "@next/swc-darwin-x64": "16.1.3", "@next/swc-linux-arm64-gnu": "16.1.3", "@next/swc-linux-arm64-musl": "16.1.3", "@next/swc-linux-x64-gnu": "16.1.3", "@next/swc-linux-x64-musl": "16.1.3", "@next/swc-win32-arm64-msvc": "16.1.3", "@next/swc-win32-x64-msvc": "16.1.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw=="],
- "@opencut/web/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
+ "@opencut/web/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "@opennextjs/aws/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
+ "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
+
"@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1819,54 +2262,62 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
- "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
- "@types/pg/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="],
+ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
- "@upstash/core-analytics/@upstash/redis": ["@upstash/redis@1.35.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-sIMuAMU9IYbE2bkgDby8KLoQKRiBMXn0moXxqLvUmQ7VUu2CvulZLtK8O0x3WQZFvvZhU5sRC2/lOVZdGfudkA=="],
+ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
- "better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
+ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"cloudflare/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
- "cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
-
- "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
-
- "drizzle-kit/esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="],
-
"eciesjs/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
- "finalhandler/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+ "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
+
+ "global-agent/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
+ "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
+
+ "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+
+ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
+
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.22.0-dev.20250409-89f8206ba4", "", {}, "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ=="],
+ "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+ "path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
+
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -1875,22 +2326,24 @@
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "react-day-picker/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
-
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
- "router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
- "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
+ "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
- "send/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
+ "tsx/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
+
"wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
+ "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+
"wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
@@ -1951,6 +2404,26 @@
"@node-minify/core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+ "@opencut/web/next/@next/env": ["@next/env@16.1.3", "", {}, "sha512-BLP14oBOvZWXgfdJf9ao+VD8O30uE+x7PaV++QtACLX329WcRSJRO5YJ+Bcvu0Q+c/lei41TjSiFf6pXqnpbQA=="],
+
+ "@opencut/web/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CpOD3lmig6VflihVoGxiR/l5Jkjfi4uLaOR4ziriMv0YMDoF6cclI+p5t2nstM8TmaFiY6PCTBgRWB57/+LiBA=="],
+
+ "@opencut/web/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-aF4us2JXh0zn3hNxvL1Bx3BOuh8Lcw3p3Xnurlvca/iptrDH1BrpObwkw9WZra7L7/0qB9kjlREq3hN/4x4x+Q=="],
+
+ "@opencut/web/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8VRkcpcfBtYvhGgXAF7U3MBx6+G1lACM1XCo1JyaUr4KmAkTNP8Dv2wdMq7BI+jqRBw3zQE7c57+lmp7jCFfKA=="],
+
+ "@opencut/web/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-UbFx69E2UP7MhzogJRMFvV9KdEn4sLGPicClwgqnLht2TEi204B71HuVfps3ymGAh0c44QRAF+ZmvZZhLLmhNg=="],
+
+ "@opencut/web/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SzGTfTjR5e9T+sZh5zXqG/oeRQufExxBF6MssXS7HPeZFE98JDhCRZXpSyCfWrWrYrzmnw/RVhlP2AxQm+wkRQ=="],
+
+ "@opencut/web/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-HlrDpj0v+JBIvQex1mXHq93Mht5qQmfyci+ZNwGClnAQldSfxI6h0Vupte1dSR4ueNv4q7qp5kTnmLOBIQnGow=="],
+
+ "@opencut/web/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3gFCp83/LSduZMSIa+lBREP7+5e7FxpdBoc9QrCdmp+dapmTK9I+SLpY60Z39GDmTXSZA4huGg9WwmYbr6+WRw=="],
+
+ "@opencut/web/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-1SZVfFT8zmMB+Oblrh5OKDvUo5mYQOkX2We6VGzpg7JUVZlqe4DYOFGKYZKTweSx1gbMixyO1jnFT4thU+nNHQ=="],
+
+ "@opencut/web/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+
"@opennextjs/aws/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"@opennextjs/aws/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
@@ -2063,81 +2536,75 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"cloudflare/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
- "cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
-
- "cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
-
- "cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
-
- "cmdk/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
- "cmdk/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
- "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
- "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="],
+ "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
- "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="],
+ "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
- "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="],
+ "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
- "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="],
+ "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
- "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="],
+ "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
- "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="],
+ "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
- "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="],
+ "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
- "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="],
+ "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
- "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="],
+ "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
- "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="],
+ "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
- "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="],
+ "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
- "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="],
+ "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
- "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="],
+ "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
- "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="],
+ "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
- "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="],
+ "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
- "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="],
+ "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
- "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="],
+ "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
- "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="],
+ "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
- "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="],
+ "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
- "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="],
+ "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
- "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="],
+ "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
- "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="],
+ "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
- "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="],
+ "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
- "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="],
+ "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
- "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="],
+ "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
- "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="],
+ "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
- "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+ "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
- "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
- "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+ "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
@@ -2199,12 +2666,16 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
- "@node-minify/core/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+ "@node-minify/core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
"@node-minify/core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
- "@node-minify/core/glob/path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
+ "@node-minify/core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
+
+ "@opencut/web/next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
- "@node-minify/core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+ "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
}
}
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 00000000..fd56f7d0
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,96 @@
+import js from "@eslint/js";
+import next from "@next/eslint-plugin-next";
+import eslintConfigPrettier from "eslint-config-prettier/flat";
+import jsxA11y from "eslint-plugin-jsx-a11y";
+import react from "eslint-plugin-react";
+import reactHooks from "eslint-plugin-react-hooks";
+import globals from "globals";
+import tseslint from "typescript-eslint";
+import preferObjectParams from "./eslint/rules/prefer-object-params.mjs";
+
+const webFiles = ["apps/web/src/**/*.{ts,tsx}"];
+
+const opencutEslintPlugin = {
+ meta: {
+ name: "eslint-plugin-opencut",
+ version: "0.0.0",
+ },
+ rules: {
+ "prefer-object-params": preferObjectParams,
+ },
+};
+
+function scopeToWebFiles(config) {
+ return {
+ ...config,
+ files: webFiles,
+ };
+}
+
+export default [
+ {
+ ignores: ["**/.next/**", "**/node_modules/**", "**/dist/**", "**/build/**"],
+ },
+ {
+ files: webFiles,
+ languageOptions: {
+ ecmaVersion: "latest",
+ sourceType: "module",
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ projectService: true,
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ linterOptions: {
+ reportUnusedDisableDirectives: "error",
+ },
+ settings: {
+ react: {
+ version: "detect",
+ },
+ },
+ },
+ scopeToWebFiles(js.configs.recommended),
+ ...tseslint.configs.recommended.map(scopeToWebFiles),
+ scopeToWebFiles(react.configs.flat.recommended),
+ scopeToWebFiles(react.configs.flat["jsx-runtime"]),
+ scopeToWebFiles(reactHooks.configs.flat["recommended-latest"]),
+ scopeToWebFiles(jsxA11y.flatConfigs.recommended),
+ scopeToWebFiles(next.configs["core-web-vitals"]),
+ {
+ files: webFiles,
+ plugins: {
+ opencut: opencutEslintPlugin,
+ },
+ rules: {
+ "@typescript-eslint/no-empty-object-type": "warn",
+ "@typescript-eslint/no-unsafe-type-assertion": "error",
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ argsIgnorePattern: "^_",
+ caughtErrorsIgnorePattern: "^_",
+ destructuredArrayIgnorePattern: "^_",
+ varsIgnorePattern: "^_",
+ },
+ ],
+ "no-empty": "warn",
+ "opencut/prefer-object-params": "error",
+
+ // `react/prop-types` is for the JS-era React workflow where runtime
+ // `propTypes` declarations are the prop contract. In this TS-only
+ // scope the prop types already are the contract; the rule's only
+ // effect is false positives when it can't trace destructured props
+ // back to a `propTypes` definition that doesn't exist.
+ "react/prop-types": "off",
+ },
+ },
+ scopeToWebFiles(eslintConfigPrettier),
+];
diff --git a/eslint/rules/__tests__/prefer-object-params.test.mjs b/eslint/rules/__tests__/prefer-object-params.test.mjs
new file mode 100644
index 00000000..7f4bc947
--- /dev/null
+++ b/eslint/rules/__tests__/prefer-object-params.test.mjs
@@ -0,0 +1,112 @@
+import { afterAll, describe, it } from "bun:test";
+import { RuleTester } from "eslint";
+import tseslint from "typescript-eslint";
+import rule from "../prefer-object-params.mjs";
+
+RuleTester.describe = describe;
+RuleTester.it = it;
+RuleTester.itOnly = it.only;
+RuleTester.afterAll = afterAll;
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parser: tseslint.parser,
+ parserOptions: {
+ ecmaVersion: "latest",
+ sourceType: "module",
+ ecmaFeatures: { jsx: true },
+ },
+ },
+});
+
+const error = { messageId: "preferObjectParams" };
+
+ruleTester.run("prefer-object-params", rule, {
+ valid: [
+ { code: "function f() {}" },
+ { code: "function f(a) {}" },
+ { code: "const f = (a) => a;" },
+ { code: "const f = function (a) { return a; };" },
+ { code: "function f({ a, b }) { return a + b; }" },
+ { code: "function f({ a, b }: { a: number; b: number }) { return a + b; }" },
+
+ // Direct callbacks: positional args are dictated by the caller.
+ { code: "[1, 2, 3].reduce((acc, cur) => acc + cur, 0);" },
+ { code: "arr.map((value, index) => value + index);" },
+ { code: "new Promise((resolve, reject) => {});" },
+ { code: "maybeFn?.((a, b) => a + b);" },
+
+ // Inline option-object callbacks: caller dictates the signature
+ // (e.g. zustand's `persist`, tanstack-query's `onSuccess`).
+ { code: "someAPI({ on: (a, b) => a + b });" },
+ {
+ code: "persist((set, get) => ({}), { migrate: (state, version) => state });",
+ },
+ { code: "new Foo({ handler: (a, b) => a + b });" },
+ { code: "maybeFn?.({ handler: (a, b) => a + b });" },
+
+ // Deeply-nested inline option callbacks still qualify: the entire
+ // option tree is supplied at the call site, so the caller dictates
+ // every signature inside it. Real example: better-auth's
+ // `betterAuth({ rateLimit: { customStorage: { set: (k, v) => ... }}})`.
+ { code: "someAPI({ outer: { handler: (a, b) => a + b } });" },
+
+ // Type predicates require a positional subject.
+ {
+ code: "function isFoo(x: unknown, ctx: Ctx): x is Foo { return true; }",
+ },
+ {
+ code: "const isFoo = (x: unknown, ctx: Ctx): x is Foo => true;",
+ },
+ ],
+ invalid: [
+ {
+ code: "function f(a, b) { return a + b; }",
+ errors: [error],
+ },
+ {
+ code: "const f = (a, b) => a + b;",
+ errors: [error],
+ },
+ {
+ code: "const f = function (a, b) { return a + b; };",
+ errors: [error],
+ },
+ {
+ code: "function f(a, b, c) { return a + b + c; }",
+ errors: [error],
+ },
+ {
+ code: "function formatTime(seconds: number, fps: number) { return seconds * fps; }",
+ errors: [error],
+ },
+ // A callback assigned to a name and passed indirectly is still a
+ // signature the author controls; the carve-out is intentionally narrow
+ // to direct callbacks.
+ {
+ code: "const cb = (a, b) => a + b; arr.reduce(cb, 0);",
+ errors: [error],
+ },
+ // Pre-declared option object: once the object is named, its callback
+ // signatures are the author's to evolve.
+ {
+ code: "const opts = { handler: (a, b) => a + b }; someAPI(opts);",
+ errors: [error],
+ },
+ // The error must point at the function's declaration line, so a
+ // `// eslint-disable-next-line` placed above the declaration (the
+ // conventional spot) suppresses the violation even when the
+ // signature spans multiple lines. Reporting at a parameter row
+ // instead leaves the directive inert and `reportUnusedDisableDirectives`
+ // then flags every suppressed call site.
+ {
+ code: [
+ "function f(",
+ "\ta: number,",
+ "\tb: number,",
+ ") { return a + b; }",
+ ].join("\n"),
+ errors: [{ messageId: "preferObjectParams", line: 1 }],
+ },
+ ],
+});
diff --git a/eslint/rules/prefer-object-params.mjs b/eslint/rules/prefer-object-params.mjs
new file mode 100644
index 00000000..a5a8caae
--- /dev/null
+++ b/eslint/rules/prefer-object-params.mjs
@@ -0,0 +1,107 @@
+export default {
+ meta: {
+ type: "suggestion",
+ docs: {
+ description:
+ "Prefer a single destructured object parameter over multiple positional parameters",
+ },
+ schema: [],
+ messages: {
+ preferObjectParams:
+ "Use a single destructured object parameter instead of multiple positional parameters. Example: function someFunction({ name, age }: { name: string, age: number }) {}",
+ },
+ },
+ create(context) {
+ function isCallArgument(node) {
+ return (
+ node &&
+ (node.type === "CallExpression" ||
+ node.type === "NewExpression" ||
+ node.type === "OptionalCallExpression")
+ );
+ }
+
+ function isDirectCallback(node) {
+ const parent = node.parent;
+ return (
+ isCallArgument(parent) && parent.arguments.includes(node)
+ );
+ }
+
+ // Some APIs receive callbacks via a property somewhere inside an inline
+ // option object (e.g. `persist(state, { migrate: (s, v) => ... })` or
+ // `betterAuth({ rateLimit: { customStorage: { set: (k, v) => ... }}})`).
+ // The caller still dictates the signature, so the same carve-out as
+ // `isDirectCallback` applies. Pre-declared option objects don't qualify —
+ // once you name the object, the signature is yours to evolve.
+ function isInlineOptionCallback(node) {
+ let current = node;
+ let property = current.parent;
+ if (
+ !property ||
+ property.type !== "Property" ||
+ property.value !== current
+ ) {
+ return false;
+ }
+
+ let objectExpression = property.parent;
+ while (objectExpression?.type === "ObjectExpression") {
+ const parent = objectExpression.parent;
+ if (isCallArgument(parent) && parent.arguments.includes(objectExpression)) {
+ return true;
+ }
+
+ if (
+ parent?.type !== "Property" ||
+ parent.value !== objectExpression
+ ) {
+ return false;
+ }
+
+ property = parent;
+ objectExpression = property.parent;
+ }
+
+ return false;
+ }
+
+ // Type predicates (`x is Foo`) require a positional subject by language
+ // rule, so the readability/evolution argument doesn't apply. AGENTS.md
+ // carves these out explicitly.
+ function isTypePredicate(node) {
+ return node.returnType?.typeAnnotation?.type === "TSTypePredicate";
+ }
+
+ function reportIfNeeded(node) {
+ if (
+ node.params.length <= 1 ||
+ isDirectCallback(node) ||
+ isInlineOptionCallback(node) ||
+ isTypePredicate(node)
+ ) {
+ return;
+ }
+
+ // Report from the function's first line through params[1] so a
+ // conventional `// eslint-disable-next-line` placed above the
+ // declaration suppresses the violation. The directive matches
+ // against `loc.start.line`, which must land on the function
+ // declaration line — not on a later parameter row.
+ context.report({
+ node,
+ loc: {
+ start: node.loc.start,
+ end: node.params[1].loc.end,
+ },
+ messageId: "preferObjectParams",
+ });
+ }
+
+ return {
+ ArrowFunctionExpression: reportIfNeeded,
+ FunctionDeclaration: reportIfNeeded,
+ FunctionExpression: reportIfNeeded,
+ };
+ },
+};
diff --git a/package.json b/package.json
index 22504650..0e58b6bb 100644
--- a/package.json
+++ b/package.json
@@ -13,25 +13,36 @@
"dev:tools": "turbo run dev --filter=@opencut/tools",
"dev:wasm": "cargo watch -w rust/crates -w rust/wasm/src -s 'wasm-pack build rust/wasm --target bundler --out-dir pkg'",
"dev:web": "turbo run dev --filter=@opencut/web",
- "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000",
+ "format:web": "prettier apps/web/src/services/renderer --write",
"generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts",
- "lint:web": "biome lint apps/web/src --max-diagnostics=1000",
- "lint:web:fix": "biome lint apps/web/src --write --unsafe --max-diagnostics=1000",
+ "lint:web": "eslint apps/web/src --ext .ts,.tsx",
+ "lint:web:fix": "eslint apps/web/src --ext .ts,.tsx --fix",
"preview:web": "turbo run preview --filter=@opencut/web",
"publish:wasm": "bun run build:wasm && npm publish rust/wasm/pkg --access public",
"start:tools": "turbo run start --filter=@opencut/tools",
"test": "bun test"
},
"dependencies": {
- "@biomejs/biome": "^2.4.8",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"better-auth": "^1.4.15",
- "next": "^16.1.3"
+ "next": "^16.1.3",
+ "opencut": ".",
+ "opencut-wasm": "^0.2.10"
},
"devDependencies": {
+ "@eslint/js": "^9.39.4",
+ "@next/eslint-plugin-next": "^16.2.4",
+ "eslint": "^9.39.4",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "globals": "^17.5.0",
+ "prettier": "^3.8.3",
"turbo": "^2.8.20",
- "typescript": "^6.0.2"
+ "typescript": "^6.0.2",
+ "typescript-eslint": "^8.59.0"
},
"trustedDependencies": [
"@tailwindcss/oxide"
diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml
index 3fbfe5ba..0f2bfc56 100644
--- a/rust/wasm/Cargo.toml
+++ b/rust/wasm/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "opencut-wasm"
-version = "0.2.9"
+version = "0.2.10"
edition = "2024"
description = "Shared video editor logic compiled to WebAssembly"
repository = "https://github.com/opencut/opencut"