summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-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
268 files changed, 8984 insertions, 7421 deletions
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();