summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/package.json6
-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.ts46
-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/property-registry.ts119
-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.ts5
-rw-r--r--apps/web/src/clipboard/types.ts8
-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.ts16
-rw-r--r--apps/web/src/commands/scene/remove-bookmark.ts4
-rw-r--r--apps/web/src/commands/scene/rename-scene.ts16
-rw-r--r--apps/web/src/commands/scene/toggle-bookmark.ts4
-rw-r--r--apps/web/src/commands/scene/update-bookmark.ts16
-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/editor/panels/assets/assets-panel-store.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx7
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings/index.tsx32
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts192
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts350
-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/stores/properties-store.ts9
-rw-r--r--apps/web/src/core/index.ts2
-rw-r--r--apps/web/src/core/managers/media-manager.ts12
-rw-r--r--apps/web/src/core/managers/save-manager.ts13
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts11
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts7
-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.tsx478
-rw-r--r--apps/web/src/graphics/definitions/index.ts5
-rw-r--r--apps/web/src/graphics/index.ts304
-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/custom-path.ts42
-rw-r--r--apps/web/src/masks/definitions/split.ts26
-rw-r--r--apps/web/src/masks/registry.ts5
-rw-r--r--apps/web/src/media/use-paste-media.ts6
-rw-r--r--apps/web/src/params/index.ts100
-rw-r--r--apps/web/src/params/registry.ts86
-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/preview-viewport.tsx16
-rw-r--r--apps/web/src/preview/components/text-edit-overlay.tsx1
-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/rendering/components/blending-tab.tsx231
-rw-r--r--apps/web/src/rendering/components/transform-tab.tsx462
-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.ts23
-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/resolve.ts10
-rw-r--r--apps/web/src/services/storage/indexeddb-adapter.ts18
-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.ts37
-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__/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/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.ts17
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts15
-rw-r--r--apps/web/src/services/storage/opfs-adapter.ts8
-rw-r--r--apps/web/src/services/storage/service.ts55
-rw-r--r--apps/web/src/services/storage/types.ts2
-rw-r--r--apps/web/src/stickers/providers/index.ts48
-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/text/components/assets-view.tsx94
-rw-r--r--apps/web/src/text/components/text-tab.tsx767
-rw-r--r--apps/web/src/timeline/__tests__/update-pipeline.test.ts132
-rw-r--r--apps/web/src/timeline/animation-properties.ts426
-rw-r--r--apps/web/src/timeline/animation-targets.ts2
-rw-r--r--apps/web/src/timeline/components/audio-volume-line.tsx3
-rw-r--r--apps/web/src/timeline/components/audio-waveform.tsx60
-rw-r--r--apps/web/src/timeline/components/graph-editor/bezier-graph.tsx5
-rw-r--r--apps/web/src/timeline/components/index.tsx41
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx59
-rw-r--r--apps/web/src/timeline/components/timeline-playhead.tsx12
-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.ts5
-rw-r--r--apps/web/src/timeline/controllers/element-interaction-controller.ts85
-rw-r--r--apps/web/src/timeline/controllers/resize-controller.ts13
-rw-r--r--apps/web/src/timeline/controllers/zoom-controller.ts25
-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.ts16
-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.ts13
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-resize.ts14
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-seek.ts14
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-zoom.ts17
-rw-r--r--apps/web/src/timeline/index.ts9
-rw-r--r--apps/web/src/timeline/types.ts664
-rw-r--r--apps/web/src/utils/geometry.ts10
-rw-r--r--apps/web/src/wasm/media-time.ts90
-rw-r--r--apps/web/tsconfig.json78
147 files changed, 3134 insertions, 4517 deletions
diff --git a/apps/web/package.json b/apps/web/package.json
index bbc3ad0a..90cd9fb5 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,9 +9,9 @@
"start": "next start",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
- "lint": "biome check src/",
- "lint:fix": "biome check src/ --write",
- "format": "biome format src/ --write",
+ "lint": "eslint src --ext .ts,.tsx",
+ "lint:fix": "eslint src --ext .ts,.tsx --fix",
+ "format": "prettier src --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
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 5e90a6a1..73300c6e 100644
--- a/apps/web/src/actions/use-editor-actions.ts
+++ b/apps/web/src/actions/use-editor-actions.ts
@@ -1,6 +1,6 @@
"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";
@@ -25,6 +25,7 @@ import {
clearActiveScope,
type ScopeEntry,
} from "@/selection/scope";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
export function useEditorActions() {
const editor = useEditor();
@@ -36,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",
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/property-registry.ts b/apps/web/src/animation/property-registry.ts
index aacb9972..bca9d82e 100644
--- a/apps/web/src/animation/property-registry.ts
+++ b/apps/web/src/animation/property-registry.ts
@@ -32,7 +32,11 @@ export interface AnimationPropertyDefinition {
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
- setValue: ({
+ // Apply `value` to `element` for this property. Coerces the value through
+ // `coerceValue` and verifies element support; returns `element` unchanged
+ // if either fails. Cannot be bypassed — there is no kind-narrow `setValue`
+ // on the public surface, so callers can't apply an unvalidated value.
+ applyValue: ({
element,
value,
}: {
@@ -94,7 +98,13 @@ function createNumberPropertyDefinition({
numericRange?: NumericSpec;
supportsElement: AnimationPropertyDefinition["supportsElement"];
getValue: AnimationPropertyDefinition["getValue"];
- setValue: AnimationPropertyDefinition["setValue"];
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: number;
+ }) => TimelineElement;
}): AnimationPropertyDefinition {
return {
kind: "number",
@@ -102,12 +112,51 @@ function createNumberPropertyDefinition({
numericRanges: numericRange ? { value: numericRange } : undefined,
supportsElement,
getValue,
- coerceValue: ({ value }) =>
- coerceNumberValue({
- value,
- numericRange,
- }),
- setValue,
+ coerceValue: ({ value }) => coerceNumberValue({ value, numericRange }),
+ applyValue: ({ element, value }) => {
+ if (!supportsElement({ element })) {
+ return element;
+ }
+ const coerced = coerceNumberValue({ value, numericRange });
+ if (coerced === null) {
+ return element;
+ }
+ return setValue({ element, value: coerced });
+ },
+ };
+}
+
+function createColorPropertyDefinition({
+ supportsElement,
+ getValue,
+ setValue,
+}: {
+ supportsElement: AnimationPropertyDefinition["supportsElement"];
+ getValue: AnimationPropertyDefinition["getValue"];
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: string;
+ }) => TimelineElement;
+}): AnimationPropertyDefinition {
+ return {
+ kind: "color",
+ defaultInterpolation: "linear",
+ supportsElement,
+ getValue,
+ coerceValue: ({ value }) => coerceColorValue({ value }),
+ applyValue: ({ element, value }) => {
+ if (!supportsElement({ element })) {
+ return element;
+ }
+ const coerced = coerceColorValue({ value });
+ if (coerced === null) {
+ return element;
+ }
+ return setValue({ element, value: coerced });
+ },
};
}
@@ -126,7 +175,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
...element,
transform: {
...element.transform,
- position: { ...element.transform.position, x: value as number },
+ position: { ...element.transform.position, x: value },
},
}
: element,
@@ -142,7 +191,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
...element,
transform: {
...element.transform,
- position: { ...element.transform.position, y: value as number },
+ position: { ...element.transform.position, y: value },
},
}
: element,
@@ -156,7 +205,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
isVisualElement(element)
? {
...element,
- transform: { ...element.transform, scaleX: value as number },
+ transform: { ...element.transform, scaleX: value },
}
: element,
}),
@@ -169,7 +218,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
isVisualElement(element)
? {
...element,
- transform: { ...element.transform, scaleY: value as number },
+ transform: { ...element.transform, scaleY: value },
}
: element,
}),
@@ -182,7 +231,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
isVisualElement(element)
? {
...element,
- transform: { ...element.transform, rotate: value as number },
+ transform: { ...element.transform, rotate: value },
}
: element,
}),
@@ -192,9 +241,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
setValue: ({ element, value }) =>
- isVisualElement(element)
- ? { ...element, opacity: value as number }
- : element,
+ isVisualElement(element) ? { ...element, opacity: value } : element,
}),
volume: createNumberPropertyDefinition({
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
@@ -202,36 +249,26 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
getValue: ({ element }) =>
canElementHaveAudio(element) ? element.volume ?? 0 : null,
setValue: ({ element, value }) =>
- canElementHaveAudio(element)
- ? { ...element, volume: value as number }
- : element,
+ canElementHaveAudio(element) ? { ...element, volume: value } : element,
}),
- color: {
- kind: "color",
- defaultInterpolation: "linear",
+ color: createColorPropertyDefinition({
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",
+ element.type === "text" ? { ...element, color: value } : element,
+ }),
+ "background.color": createColorPropertyDefinition({
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 },
+ background: { ...element.background, color: value },
}
: element,
- },
+ }),
"background.paddingX": createNumberPropertyDefinition({
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
@@ -243,7 +280,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
element.type === "text"
? {
...element,
- background: { ...element.background, paddingX: value as number },
+ background: { ...element.background, paddingX: value },
}
: element,
}),
@@ -258,7 +295,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
element.type === "text"
? {
...element,
- background: { ...element.background, paddingY: value as number },
+ background: { ...element.background, paddingY: value },
}
: element,
}),
@@ -273,7 +310,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
element.type === "text"
? {
...element,
- background: { ...element.background, offsetX: value as number },
+ background: { ...element.background, offsetX: value },
}
: element,
}),
@@ -288,7 +325,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
element.type === "text"
? {
...element,
- background: { ...element.background, offsetY: value as number },
+ background: { ...element.background, offsetY: value },
}
: element,
}),
@@ -307,7 +344,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
element.type === "text"
? {
...element,
- background: { ...element.background, cornerRadius: value as number },
+ background: { ...element.background, cornerRadius: value },
}
: element,
}),
@@ -362,11 +399,7 @@ export function withElementBaseValueForProperty({
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 });
+ return definition.applyValue({ element, value });
}
export function getDefaultInterpolationForProperty({
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 cb0c0e65..06879e3e 100644
--- a/apps/web/src/clipboard/handlers/keyframes.ts
+++ b/apps/web/src/clipboard/handlers/keyframes.ts
@@ -156,7 +156,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 562f9ac7..1d469a30 100644
--- a/apps/web/src/clipboard/types.ts
+++ b/apps/web/src/clipboard/types.ts
@@ -69,10 +69,10 @@ 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 f3628b28..45b7bda5 100644
--- a/apps/web/src/commands/scene/move-bookmark.ts
+++ b/apps/web/src/commands/scene/move-bookmark.ts
@@ -8,13 +8,21 @@ import type { MediaTime } from "@/wasm";
export class MoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
- constructor(
- private fromTime: MediaTime,
- private toTime: MediaTime,
- ) {
+ 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 bff5c035..95403e0e 100644
--- a/apps/web/src/commands/scene/remove-bookmark.ts
+++ b/apps/web/src/commands/scene/remove-bookmark.ts
@@ -6,11 +6,11 @@ import {
getFrameTime,
removeBookmarkFromArray,
} from "@/timeline/bookmarks/index";
-import type { MediaTime } from "@/wasm";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
- private frameTime: MediaTime = 0 as MediaTime;
+ private frameTime: MediaTime = ZERO_MEDIA_TIME;
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 9f318f80..31751be4 100644
--- a/apps/web/src/commands/scene/toggle-bookmark.ts
+++ b/apps/web/src/commands/scene/toggle-bookmark.ts
@@ -6,11 +6,11 @@ import {
getFrameTime,
toggleBookmarkInArray,
} from "@/timeline/bookmarks/index";
-import type { MediaTime } from "@/wasm";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
- private frameTime: MediaTime = 0 as MediaTime;
+ private frameTime: MediaTime = ZERO_MEDIA_TIME;
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 238bb3de..7208abc4 100644
--- a/apps/web/src/commands/scene/update-bookmark.ts
+++ b/apps/web/src/commands/scene/update-bookmark.ts
@@ -11,13 +11,21 @@ import type { MediaTime } from "@/wasm";
export class UpdateBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null;
- constructor(
- private time: MediaTime,
- 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/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/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/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index c071fdc7..00cb015f 100644
--- a/apps/web/src/components/editor/panels/assets/views/assets.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -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" });
}
};
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/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
index f6216208..91d29fd7 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
@@ -1,96 +1,96 @@
-import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
- upsertElementKeyframe,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import type { MediaTime } from "@/wasm";
-
-export function useKeyframedColorProperty({
- trackId,
- elementId,
- animations,
- propertyPath,
- localTime,
- isPlayheadWithinElementRange,
- resolvedColor,
- buildBaseUpdates,
-}: {
- trackId: string;
- elementId: string;
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- localTime: MediaTime;
- 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,
- };
-}
+import { useEditor } from "@/editor/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
+import type { MediaTime } from "@/wasm";
+
+export function useKeyframedColorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: MediaTime;
+ 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
index d52f7d97..610c2253 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
@@ -1,175 +1,175 @@
-import { useEditor } from "@/editor/use-editor";
-import {
- getKeyframeAtTime,
- hasKeyframesForPath,
- upsertElementKeyframe,
-} from "@/animation";
-import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import { snapToStep } from "@/utils/math";
-import { usePropertyDraft } from "./use-property-draft";
-import type { MediaTime } from "@/wasm";
-
-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: MediaTime;
- 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,
- };
-}
+import { useEditor } from "@/editor/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
+import { snapToStep } from "@/utils/math";
+import { usePropertyDraft } from "./use-property-draft";
+import type { MediaTime } from "@/wasm";
+
+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: MediaTime;
+ 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-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/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/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/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 c9558a6d..a4c364ce 100644
--- a/apps/web/src/core/managers/scenes-manager.ts
+++ b/apps/web/src/core/managers/scenes-manager.ts
@@ -41,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();
}
@@ -77,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 });
}
@@ -138,7 +141,7 @@ export class ScenesManager {
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 });
}
@@ -149,7 +152,7 @@ export class ScenesManager {
fromTime: MediaTime;
toTime: MediaTime;
}): Promise<void> {
- const command = new MoveBookmarkCommand(fromTime, toTime);
+ const command = new MoveBookmarkCommand({ fromTime, toTime });
this.editor.command.execute({ command });
}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 8fbfdb08..91a4eeef 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -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();
}
@@ -751,7 +751,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;
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 13cd7b84..31c4a86d 100644
--- a/apps/web/src/graphics/components/graphic-tab.tsx
+++ b/apps/web/src/graphics/components/graphic-tab.tsx
@@ -1,239 +1,239 @@
-"use client";
-
-import { useRef } from "react";
-import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
-import {
- useKeyframedParamProperty,
- type KeyframedParamPropertyResult,
-} 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 { useElementPreview } from "@/timeline/hooks/use-element-preview";
-import { useEditor } from "@/editor/use-editor";
-import {
- Section,
- SectionContent,
- SectionFields,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
-import { Button } from "@/components/ui/button";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
-import { cn } from "@/utils/ui";
-import type { MediaTime } from "@/wasm";
-
-registerDefaultGraphics();
-
-const DEFAULT_STROKE_WIDTH = 2;
-
-export function GraphicTab({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicElementParamsAtTime({
- element: liveElement,
- localTime,
- });
-
- const shapeParams = definition.params.filter((p) => p.group !== "stroke");
- const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
-
- return (
- <div className="flex flex-col">
- <Section collapsible sectionKey={`${element.id}:graphic`}>
- <SectionHeader>
- <SectionTitle>{definition.name}</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <SectionFields>
- {shapeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
- </div>
- );
-}
-
-function StrokeSection({
- element,
- trackId,
-}: {
- element: GraphicElement;
- trackId: string;
-}) {
- const editor = useEditor();
- const definition = graphicsRegistry.get(element.definitionId);
- const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
- startTime: element.startTime,
- duration: element.duration,
- });
- const { renderElement } = useElementPreview({
- trackId,
- elementId: element.id,
- fallback: element,
- });
-
- const liveElement = renderElement as GraphicElement;
- const resolvedParams = resolveGraphicElementParamsAtTime({
- element: liveElement,
- localTime,
- });
- const strokeParams = definition.params.filter((p) => p.group === "stroke");
- const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
- const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
-
- const toggleStroke = () => {
- if (isStrokeEnabled) {
- lastStrokeWidth.current = Number(
- element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
- );
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: { params: { ...element.params, strokeWidth: 0 } },
- },
- ],
- });
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- patch: {
- params: {
- ...element.params,
- strokeWidth: lastStrokeWidth.current,
- },
- },
- },
- ],
- });
- }
- };
-
- return (
- <Section
- collapsible
- defaultOpen={isStrokeEnabled}
- sectionKey={`${element.id}:stroke`}
- >
- <SectionHeader
- trailing={
- <Button
- variant="ghost"
- size="icon"
- onClick={(event) => {
- event.stopPropagation();
- toggleStroke();
- }}
- >
- <HugeiconsIcon
- icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
- strokeWidth={1}
- />
- </Button>
- }
- >
- <SectionTitle>Stroke</SectionTitle>
- </SectionHeader>
- <SectionContent
- className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
- >
- <SectionFields>
- {strokeParams.map((param) => (
- <AnimatedGraphicParamField
- key={param.key}
- param={param}
- trackId={trackId}
- element={liveElement}
- localTime={localTime}
- isPlayheadWithinElementRange={isPlayheadWithinElementRange}
- resolvedParams={resolvedParams}
- />
- ))}
- </SectionFields>
- </SectionContent>
- </Section>
- );
-}
-
-function AnimatedGraphicParamField({
- param,
- trackId,
- element,
- localTime,
- isPlayheadWithinElementRange,
- resolvedParams,
-}: {
- key?: string;
- param: ParamDefinition;
- trackId: string;
- element: GraphicElement;
- localTime: MediaTime;
- isPlayheadWithinElementRange: boolean;
- resolvedParams: ParamValues;
-}) {
- const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
- {
- param,
- trackId,
- elementId: element.id,
- animations: element.animations,
- localTime,
- isPlayheadWithinElementRange,
- resolvedValue: resolvedParams[param.key] ?? param.default,
- buildBaseUpdates: ({ value }) => ({
- params: {
- ...element.params,
- [param.key]: value,
- },
- }),
- },
- );
-
- return (
- <PropertyParamField
- param={param}
- value={resolvedParams[param.key] ?? param.default}
- onPreview={animatedParam.onPreview}
- onCommit={animatedParam.onCommit}
- keyframe={{
- isActive: animatedParam.isKeyframedAtTime,
- isDisabled: !isPlayheadWithinElementRange,
- onToggle: animatedParam.toggleKeyframe,
- }}
- />
- );
-}
+"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} 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 { useElementPreview } from "@/timeline/hooks/use-element-preview";
+import { useEditor } from "@/editor/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import type { MediaTime } from "@/wasm";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicElementParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible sectionKey={`${element.id}:graphic`}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {shapeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ {hasStrokeParams && <StrokeSection element={element} trackId={trackId} />}
+ </div>
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicElementParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ patch: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={isStrokeEnabled}
+ sectionKey={`${element.id}:stroke`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+ <HugeiconsIcon
+ icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Stroke</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
+ >
+ <SectionFields>
+ {strokeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ key?: string;
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: MediaTime;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty(
+ {
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ },
+ );
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedParams[param.key] ?? param.default}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={{
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/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 3f277d40..7b8d905e 100644
--- a/apps/web/src/graphics/index.ts
+++ b/apps/web/src/graphics/index.ts
@@ -1,149 +1,155 @@
-import { resolveGraphicParamsAtTime } from "@/animation";
-import type { ElementAnimations } from "@/animation/types";
-import { buildDefaultParamValues } from "@/params/registry";
-import type { ParamValues } from "@/params";
-import { graphicsRegistry } from "./registry";
-import {
- registerDefaultGraphics,
- ellipseGraphicDefinition,
- polygonGraphicDefinition,
- rectangleGraphicDefinition,
- starGraphicDefinition,
-} from "./definitions";
-import {
- DEFAULT_GRAPHIC_SOURCE_SIZE,
- type GraphicInstance,
- type GraphicDefinition,
-} from "./types";
-
-const graphicPreviewUrlCache = new Map<string, string>();
-
-const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
-const FALLBACK_FILL_OPACITY = 0.08;
-const FALLBACK_MIN_FONT_SIZE = 12;
-const FALLBACK_FONT_SIZE_RATIO = 0.15;
-
-function buildFallbackPreviewUrl({
- name,
- size,
-}: {
- name: string;
- size: number;
-}): string {
- const svg = `
- <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
- <rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
- <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
- </svg>
- `;
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
-}
-
-export function getGraphicDefinition({
- definitionId,
-}: {
- definitionId: string;
-}): GraphicDefinition {
- registerDefaultGraphics();
- return graphicsRegistry.get(definitionId);
-}
-
-export function buildDefaultGraphicInstance({
- definitionId,
-}: {
- definitionId: string;
-}): GraphicInstance {
- const definition = getGraphicDefinition({ definitionId });
- return {
- definitionId,
- params: buildDefaultParamValues(definition.params),
- };
-}
-
-export function resolveGraphicParams(
- definition: GraphicDefinition,
- params?: ParamValues,
-): ParamValues {
- return {
- ...buildDefaultParamValues(definition.params),
- ...(params ?? {}),
- };
-}
-
-export function resolveGraphicElementParamsAtTime({
- element,
- localTime,
-}: {
- element: {
- definitionId: string;
- params: ParamValues;
- animations?: ElementAnimations;
- };
- localTime: number;
-}): ParamValues {
- const definition = getGraphicDefinition({
- definitionId: element.definitionId,
- });
- return resolveGraphicParamsAtTime({
- params: resolveGraphicParams(definition, element.params),
- definitions: definition.params,
- animations: element.animations,
- localTime,
- });
-}
-
-export function buildGraphicPreviewUrl({
- definitionId,
- params,
- size = DEFAULT_GRAPHIC_SOURCE_SIZE,
-}: {
- definitionId: string;
- params?: ParamValues;
- size?: number;
-}): string {
- const definition = getGraphicDefinition({ definitionId });
- const resolvedParams = resolveGraphicParams(definition, params);
- const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
- const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
- if (cachedUrl) {
- return cachedUrl;
- }
-
- if (typeof document === "undefined") {
- return buildFallbackPreviewUrl({ name: definition.name, size });
- }
-
- const canvas = document.createElement("canvas");
- canvas.width = size;
- canvas.height = size;
- const ctx = canvas.getContext("2d");
- if (!ctx) {
- return buildFallbackPreviewUrl({ name: definition.name, size });
- }
-
- definition.render({
- ctx,
- params: resolvedParams,
- width: size,
- height: size,
- });
-
- const previewUrl = canvas.toDataURL("image/png");
- graphicPreviewUrlCache.set(cacheKey, previewUrl);
- return previewUrl;
-}
-
-export {
- DEFAULT_GRAPHIC_SOURCE_SIZE,
- ellipseGraphicDefinition,
- graphicsRegistry,
- polygonGraphicDefinition,
- rectangleGraphicDefinition,
- registerDefaultGraphics,
- starGraphicDefinition,
-};
-export type {
- GraphicDefinition,
- GraphicInstance,
- GraphicRenderContext,
-} from "./types";
+import { resolveGraphicParamsAtTime } from "@/animation";
+import type { ElementAnimations } from "@/animation/types";
+import { buildDefaultParamValues } from "@/params/registry";
+import type { ParamValues } from "@/params";
+import { graphicsRegistry } from "./registry";
+import {
+ registerDefaultGraphics,
+ ellipseGraphicDefinition,
+ polygonGraphicDefinition,
+ rectangleGraphicDefinition,
+ starGraphicDefinition,
+} from "./definitions";
+import {
+ DEFAULT_GRAPHIC_SOURCE_SIZE,
+ type GraphicInstance,
+ type GraphicDefinition,
+} from "./types";
+
+const graphicPreviewUrlCache = new Map<string, string>();
+
+const FALLBACK_CORNER_RADIUS_RATIO = 0.2;
+const FALLBACK_FILL_OPACITY = 0.08;
+const FALLBACK_MIN_FONT_SIZE = 12;
+const FALLBACK_FONT_SIZE_RATIO = 0.15;
+
+function buildFallbackPreviewUrl({
+ name,
+ size,
+}: {
+ name: string;
+ size: number;
+}): string {
+ const svg = `
+ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
+ <rect width="${size}" height="${size}" rx="${size * FALLBACK_CORNER_RADIUS_RATIO}" fill="white" fill-opacity="${FALLBACK_FILL_OPACITY}" />
+ <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="white" font-size="${Math.max(FALLBACK_MIN_FONT_SIZE, size * FALLBACK_FONT_SIZE_RATIO)}" font-family="sans-serif">${name}</text>
+ </svg>
+ `;
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+export function getGraphicDefinition({
+ definitionId,
+}: {
+ definitionId: string;
+}): GraphicDefinition {
+ registerDefaultGraphics();
+ return graphicsRegistry.get(definitionId);
+}
+
+export function buildDefaultGraphicInstance({
+ definitionId,
+}: {
+ definitionId: string;
+}): GraphicInstance {
+ const definition = getGraphicDefinition({ definitionId });
+ return {
+ definitionId,
+ params: buildDefaultParamValues(definition.params),
+ };
+}
+
+export function resolveGraphicParams({
+ definition,
+ params,
+}: {
+ definition: GraphicDefinition;
+ params?: ParamValues;
+}): ParamValues {
+ return {
+ ...buildDefaultParamValues(definition.params),
+ ...(params ?? {}),
+ };
+}
+
+export function resolveGraphicElementParamsAtTime({
+ element,
+ localTime,
+}: {
+ element: {
+ definitionId: string;
+ params: ParamValues;
+ animations?: ElementAnimations;
+ };
+ localTime: number;
+}): ParamValues {
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ return resolveGraphicParamsAtTime({
+ params: resolveGraphicParams({
+ definition,
+ params: element.params,
+ }),
+ definitions: definition.params,
+ animations: element.animations,
+ localTime,
+ });
+}
+
+export function buildGraphicPreviewUrl({
+ definitionId,
+ params,
+ size = DEFAULT_GRAPHIC_SOURCE_SIZE,
+}: {
+ definitionId: string;
+ params?: ParamValues;
+ size?: number;
+}): string {
+ const definition = getGraphicDefinition({ definitionId });
+ const resolvedParams = resolveGraphicParams({ definition, params });
+ const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
+ const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
+ if (cachedUrl) {
+ return cachedUrl;
+ }
+
+ if (typeof document === "undefined") {
+ return buildFallbackPreviewUrl({ name: definition.name, size });
+ }
+
+ const canvas = document.createElement("canvas");
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return buildFallbackPreviewUrl({ name: definition.name, size });
+ }
+
+ definition.render({
+ ctx,
+ params: resolvedParams,
+ width: size,
+ height: size,
+ });
+
+ const previewUrl = canvas.toDataURL("image/png");
+ graphicPreviewUrlCache.set(cacheKey, previewUrl);
+ return previewUrl;
+}
+
+export {
+ DEFAULT_GRAPHIC_SOURCE_SIZE,
+ ellipseGraphicDefinition,
+ graphicsRegistry,
+ polygonGraphicDefinition,
+ rectangleGraphicDefinition,
+ registerDefaultGraphics,
+ starGraphicDefinition,
+};
+export type {
+ GraphicDefinition,
+ GraphicInstance,
+ GraphicRenderContext,
+} from "./types";
diff --git a/apps/web/src/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/custom-path.ts b/apps/web/src/masks/custom-path.ts
index 0349df63..b35702f6 100644
--- a/apps/web/src/masks/custom-path.ts
+++ b/apps/web/src/masks/custom-path.ts
@@ -330,13 +330,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,
@@ -483,7 +497,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 +511,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 +533,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;
@@ -573,12 +593,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] = {
diff --git a/apps/web/src/masks/definitions/split.ts b/apps/web/src/masks/definitions/split.ts
index d50a2571..d8fdc789 100644
--- a/apps/web/src/masks/definitions/split.ts
+++ b/apps/web/src/masks/definitions/split.ts
@@ -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
@@ -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;
}
@@ -307,13 +310,18 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
[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 isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 });
+ const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 });
if (isVertex1Inside && isVertex2Inside) {
vertices.push([x2, y2]);
diff --git a/apps/web/src/masks/registry.ts b/apps/web/src/masks/registry.ts
index 00b276c8..0bcf1cf0 100644
--- a/apps/web/src/masks/registry.ts
+++ b/apps/web/src/masks/registry.ts
@@ -115,7 +115,10 @@ export class MasksRegistry extends DefinitionRegistry<
},
icon,
};
- this.register(definition.type, withBaseParams);
+ this.register({
+ key: definition.type,
+ definition: withBaseParams,
+ });
}
}
diff --git a/apps/web/src/media/use-paste-media.ts b/apps/web/src/media/use-paste-media.ts
index 387b2b9a..7dbd4078 100644
--- a/apps/web/src/media/use-paste-media.ts
+++ b/apps/web/src/media/use-paste-media.ts
@@ -68,10 +68,10 @@ 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
diff --git a/apps/web/src/params/index.ts b/apps/web/src/params/index.ts
index 633bfbd0..23085407 100644
--- a/apps/web/src/params/index.ts
+++ b/apps/web/src/params/index.ts
@@ -1,50 +1,50 @@
-export type ParamValues = Record<string, number | string | boolean>;
-
-export type ParamGroup = "stroke";
-
-interface BaseParamDefinition<TKey extends string = string> {
- key: TKey;
- label: string;
- group?: ParamGroup;
-}
-
-export interface NumberParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "number";
- default: number;
- min: number;
- max?: number;
- step: number;
- /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
- displayMultiplier?: number;
- /** Show as percentage of max. min/max/step/default stay in stored space. */
- unit?: "percent";
- /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
- shortLabel?: string;
-}
-
-export interface BooleanParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "boolean";
- default: boolean;
-}
-
-export interface ColorParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "color";
- default: string;
-}
-
-export interface SelectParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "select";
- default: string;
- options: Array<{ value: string; label: string }>;
-}
-
-export type ParamDefinition<TKey extends string = string> =
- | NumberParamDefinition<TKey>
- | BooleanParamDefinition<TKey>
- | ColorParamDefinition<TKey>
- | SelectParamDefinition<TKey>;
-
+export type ParamValues = Record<string, number | string | boolean>;
+
+export type ParamGroup = "stroke";
+
+interface BaseParamDefinition<TKey extends string = string> {
+ key: TKey;
+ label: string;
+ group?: ParamGroup;
+}
+
+export interface NumberParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "number";
+ default: number;
+ min: number;
+ max?: number;
+ step: number;
+ /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
+ displayMultiplier?: number;
+ /** Show as percentage of max. min/max/step/default stay in stored space. */
+ unit?: "percent";
+ /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
+ shortLabel?: string;
+}
+
+export interface BooleanParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "boolean";
+ default: boolean;
+}
+
+export interface ColorParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "color";
+ default: string;
+}
+
+export interface SelectParamDefinition<TKey extends string = string>
+ extends BaseParamDefinition<TKey> {
+ type: "select";
+ default: string;
+ options: Array<{ value: string; label: string }>;
+}
+
+export type ParamDefinition<TKey extends string = string> =
+ | NumberParamDefinition<TKey>
+ | BooleanParamDefinition<TKey>
+ | ColorParamDefinition<TKey>
+ | SelectParamDefinition<TKey>;
+
diff --git a/apps/web/src/params/registry.ts b/apps/web/src/params/registry.ts
index d9fcc266..3ce483e4 100644
--- a/apps/web/src/params/registry.ts
+++ b/apps/web/src/params/registry.ts
@@ -1,40 +1,46 @@
-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, 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,
+ 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());
+ }
+}
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/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..dd6364d6 100644
--- a/apps/web/src/preview/components/text-edit-overlay.tsx
+++ b/apps/web/src/preview/components/text-edit-overlay.tsx
@@ -120,7 +120,6 @@ export function TextEditOverlay({
transformOrigin: "center center",
}}
>
- {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
<div
ref={divRef}
contentEditable
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/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx
index 52d2f051..e69de29b 100644
--- a/apps/web/src/rendering/components/blending-tab.tsx
+++ b/apps/web/src/rendering/components/blending-tab.tsx
@@ -1,231 +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";
-import type { MediaTime } from "@/wasm";
-
-type BlendingElement = {
- id: string;
- opacity: number;
- type: ElementType;
- blendMode?: BlendMode;
- startTime: MediaTime;
- duration: MediaTime;
- 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
index f4d5a183..e69de29b 100644
--- a/apps/web/src/rendering/components/transform-tab.tsx
+++ b/apps/web/src/rendering/components/transform-tab.tsx
@@ -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/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..1bc316ef 100644
--- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts
+++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
@@ -3,7 +3,7 @@ import { masksRegistry } 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";
@@ -414,15 +414,11 @@ function buildMaskArtifacts({
const { width: canvasWidth, height: canvasHeight } = renderer;
const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`;
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;
+ const { canvas: elementMaskCanvas, context: elementMaskCtx } =
+ createCanvasSurface({
+ width: Math.round(transform.width),
+ height: Math.round(transform.height),
+ });
if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
definition.renderer.renderMask({
@@ -465,15 +461,10 @@ function buildMaskArtifacts({
const strokeTextureId = `${path}:mask-stroke`;
const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`;
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({
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/resolve.ts b/apps/web/src/services/renderer/resolve.ts
index 36a6ecb5..4637df8d 100644
--- a/apps/web/src/services/renderer/resolve.ts
+++ b/apps/web/src/services/renderer/resolve.ts
@@ -235,10 +235,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,
@@ -434,7 +434,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/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/__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
index d467c8a1..cb09e07d 100644
--- 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
@@ -1,5 +1,6 @@
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", () => {
@@ -96,18 +97,15 @@ describe("V27 to V28 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(28);
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
expect(metadata.duration).toBe(2_152_466);
- const timelineViewState = result.project.timelineViewState as Record<
- string,
- unknown
- >;
+ 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 = result.project.scenes as Array<Record<string, unknown>>;
+ const scenes = asRecordArray(result.project.scenes);
const scene = scenes[0];
expect(scene.bookmarks).toEqual([
{
@@ -118,9 +116,9 @@ describe("V27 to V28 Migration", () => {
},
]);
- const tracks = scene.tracks as Record<string, unknown>;
- const mainTrack = tracks.main as Record<string, unknown>;
- const elements = mainTrack.elements as Array<Record<string, unknown>>;
+ 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);
@@ -128,9 +126,9 @@ describe("V27 to V28 Migration", () => {
expect(element.trimEnd).toBe(15_001);
expect(element.sourceDuration).toBe(2_197_468);
- const animations = element.animations as Record<string, unknown>;
- const channels = animations.channels as Record<string, Record<string, unknown>>;
- const opacityChannel = channels.opacity;
+ const animations = asRecord(element.animations);
+ const channels = asRecord(animations.channels);
+ const opacityChannel = asRecord(channels.opacity);
expect(opacityChannel.keys).toEqual([
{
id: "key-1",
@@ -204,26 +202,23 @@ describe("V27 to V28 Migration", () => {
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(28);
- const metadata = result.project.metadata as Record<string, unknown>;
+ const metadata = asRecord(result.project.metadata);
expect(metadata.duration).toBe(120_000);
- const timelineViewState = result.project.timelineViewState as Record<
- string,
- unknown
- >;
+ const timelineViewState = asRecord(result.project.timelineViewState);
expect(timelineViewState).toEqual({
zoomLevel: 2,
scrollLeft: 300,
playheadTime: 30_000,
});
- const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const scenes = asRecordArray(result.project.scenes);
const scene = scenes[0];
expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]);
- const tracks = scene.tracks as Record<string, unknown>;
- const mainTrack = tracks.main as Record<string, unknown>;
- const element = (mainTrack.elements as Array<Record<string, unknown>>)[0];
+ 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);
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__/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/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..4477d363 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
@@ -12,19 +12,18 @@ interface CustomMaskPathPoint {
}
function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint {
- if (!value || typeof value !== "object") {
+ 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"
);
}
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/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 a388e807..e8fc26c0 100644
--- a/apps/web/src/services/storage/service.ts
+++ b/apps/web/src/services/storage/service.ts
@@ -65,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> {
@@ -91,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}`);
@@ -161,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({
@@ -305,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);
@@ -501,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;
@@ -517,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/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/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/text/components/assets-view.tsx b/apps/web/src/text/components/assets-view.tsx
index ffabe3b8..82a8e46c 100644
--- a/apps/web/src/text/components/assets-view.tsx
+++ b/apps/web/src/text/components/assets-view.tsx
@@ -1,47 +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";
-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: 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: DEFAULTS.text.element.content,
+ }}
+ 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
index 0f0b274a..e69de29b 100644
--- a/apps/web/src/text/components/text-tab.tsx
+++ b/apps/web/src/text/components/text-tab.tsx
@@ -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/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts
index 94b32ced..e6d63bd6 100644
--- a/apps/web/src/timeline/__tests__/update-pipeline.test.ts
+++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts
@@ -1,66 +1,66 @@
-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",
- transform: buildTransform(),
- 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);
- });
-});
+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",
+ transform: buildTransform(),
+ 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 308f6cf0..00000000
--- a/apps/web/src/timeline/animation-properties.ts
+++ /dev/null
@@ -1,426 +0,0 @@
-import type {
- AnimationBindingKind,
- AnimationInterpolation,
- AnimationPropertyPath,
- AnimationValue,
- ElementAnimations,
- NumericSpec,
-} from "@/animation/types";
-import { upsertPathKeyframe } from "@/animation";
-import { parseColorToLinearRgba } from "@/animation/binding-values";
-import type { MediaTime } from "@/wasm";
-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: MediaTime;
- 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-targets.ts b/apps/web/src/timeline/animation-targets.ts
index f9d53fcc..fe683f92 100644
--- a/apps/web/src/timeline/animation-targets.ts
+++ b/apps/web/src/timeline/animation-targets.ts
@@ -28,7 +28,7 @@ import {
getAnimationPropertyDefinition,
getElementBaseValueForProperty,
withElementBaseValueForProperty,
-} from "./animation-properties";
+} from "@/animation";
export interface AnimationPathDescriptor {
kind: AnimationBindingKind;
diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx
index 1baac2bd..ac2fd32a 100644
--- a/apps/web/src/timeline/components/audio-volume-line.tsx
+++ b/apps/web/src/timeline/components/audio-volume-line.tsx
@@ -242,8 +242,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/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/index.tsx b/apps/web/src/timeline/components/index.tsx
index 490ad1cc..8edbb6fc 100644
--- a/apps/web/src/timeline/components/index.tsx
+++ b/apps/web/src/timeline/components/index.tsx
@@ -29,6 +29,7 @@ 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";
@@ -80,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";
@@ -148,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();
@@ -358,8 +370,6 @@ export function Timeline() {
},
});
- const containerWidth =
- tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
const contentWidth = timelineTimeToPixels({
time: timelineDuration,
zoomLevel,
@@ -373,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,
@@ -419,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
@@ -450,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}
@@ -509,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={{
@@ -891,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 cb102c71..be8409f3 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,
@@ -51,7 +52,7 @@ import { getTimelinePixelsPerSecond } from "@/timeline";
import { buildWaveformSourceKey } from "@/media/waveform-summary";
import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
import {
- getActionDefinition,
+ getActionDefaultShortcuts,
type TAction,
type TActionWithOptionalArgs,
invokeAction,
@@ -185,7 +186,7 @@ export function getKeyframeIndicators({
}
export function getDisplayShortcut({ action }: { action: TAction }) {
- const { defaultShortcuts } = getActionDefinition({ action });
+ const defaultShortcuts = getActionDefaultShortcuts({ action });
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;
}
@@ -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(
@@ -770,9 +771,7 @@ 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: {
@@ -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>
);
}
@@ -1073,7 +1064,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 5b1e45ab..6d77789e 100644
--- a/apps/web/src/timeline/components/timeline-playhead.tsx
+++ b/apps/web/src/timeline/components/timeline-playhead.tsx
@@ -1,11 +1,13 @@
"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 {
addMediaTime,
@@ -52,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 -
@@ -68,7 +73,6 @@ export function TimelinePlayhead({
time: currentTime,
zoomLevel,
});
- const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
const leftPosition =
getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft;
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 7fca2676..e0624a74 100644
--- a/apps/web/src/timeline/controllers/drag-drop-controller.ts
+++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts
@@ -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,
diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts
index d0d6426a..5afc3a93 100644
--- a/apps/web/src/timeline/controllers/element-interaction-controller.ts
+++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts
@@ -124,20 +124,27 @@ const IDLE_VIEW: ElementDragView = { kind: "idle" };
// --- Pure helpers ---
-function pixelToClickOffsetTime(
- clientX: number,
- elementRect: DOMRect,
- zoomLevel: number,
-): MediaTime {
+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 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;
@@ -147,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
@@ -374,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,
},
};
@@ -437,10 +450,13 @@ export class ElementInteractionController {
this.notify();
}
- private snapResult(
- frameSnappedTime: MediaTime,
- group: MoveGroup,
- ): { snappedTime: MediaTime; 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()) {
@@ -487,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
@@ -543,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;
}
@@ -568,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
@@ -632,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;
@@ -666,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/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts
index e8cbaa80..a1098915 100644
--- a/apps/web/src/timeline/controllers/resize-controller.ts
+++ b/apps/web/src/timeline/controllers/resize-controller.ts
@@ -263,10 +263,13 @@ export class ResizeController {
this.notify();
}
- private snappedDelta(
- session: ResizeSession,
- rawDeltaTime: MediaTime,
- ): MediaTime {
+ private snappedDelta({
+ session,
+ rawDeltaTime,
+ }: {
+ session: ResizeSession;
+ rawDeltaTime: MediaTime;
+ }): MediaTime {
const { snappingEnabled, isShiftHeld, zoomLevel } = this.config;
if (!snappingEnabled || isShiftHeld()) {
@@ -330,7 +333,7 @@ export class ResizeController {
TICKS_PER_SECOND,
),
});
- const deltaTime = this.snappedDelta(session, rawDeltaTime);
+ const deltaTime = this.snappedDelta({ session, rawDeltaTime });
const result = computeGroupResize({
members: session.members,
side: session.side,
diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts
index c5090e16..91147354 100644
--- a/apps/web/src/timeline/controllers/zoom-controller.ts
+++ b/apps/web/src/timeline/controllers/zoom-controller.ts
@@ -25,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));
}
@@ -49,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;
@@ -89,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;
@@ -115,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;
}
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 ea03cb5c..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 {
@@ -44,15 +45,10 @@ export function useKeyframeDrag({
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 15ef3575..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";
@@ -54,14 +55,8 @@ export function useTimelinePlayhead({
},
}),
};
- 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(() => {
diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts
index 3579bd75..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";
@@ -55,15 +56,8 @@ export function useTimelineResize({
}),
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 bf033dae..ea9b44a7 100644
--- a/apps/web/src/timeline/hooks/use-timeline-seek.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts
@@ -1,5 +1,6 @@
-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,
@@ -50,15 +51,8 @@ export function useTimelineSeek({
},
}),
};
-
- 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 3020504f..60768963 100644
--- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
@@ -4,9 +4,10 @@ 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,
@@ -57,21 +58,17 @@ export function useTimelineZoom({
},
}),
};
- 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 3775e006..21944561 100644
--- a/apps/web/src/timeline/index.ts
+++ b/apps/web/src/timeline/index.ts
@@ -1,4 +1,4 @@
-import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
+import { addMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
import type { SceneTracks } from "./types";
export * from "./types";
@@ -22,9 +22,10 @@ export function calculateTotalDuration({
let maxEnd: MediaTime = ZERO_MEDIA_TIME;
for (const track of orderedTracks) {
for (const element of track.elements) {
- // `startTime + duration` is integer-by-construction (both `MediaTime`),
- // so the cast re-establishes the brand without runtime work.
- const elementEnd = (element.startTime + element.duration) as MediaTime;
+ const elementEnd = addMediaTime({
+ a: element.startTime,
+ b: element.duration,
+ });
if (elementEnd > maxEnd) maxEnd = elementEnd;
}
}
diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts
index 99df3128..e84a4f4e 100644
--- a/apps/web/src/timeline/types.ts
+++ b/apps/web/src/timeline/types.ts
@@ -1,332 +1,332 @@
-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;
- elementId: string;
-};
-
-export interface Bookmark {
- time: MediaTime;
- note?: string;
- color?: string;
- duration?: MediaTime;
-}
-
-export interface TScene {
- id: string;
- name: string;
- isMain: boolean;
- tracks: SceneTracks;
- bookmarks: Bookmark[];
- createdAt: Date;
- updatedAt: Date;
-}
-
-export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
-
-interface BaseTrack {
- id: string;
- name: string;
-}
-
-export interface VideoTrack extends BaseTrack {
- type: "video";
- elements: (VideoElement | ImageElement)[];
- muted: boolean;
- hidden: boolean;
-}
-
-export interface TextTrack extends BaseTrack {
- type: "text";
- elements: TextElement[];
- hidden: boolean;
-}
-
-export interface AudioTrack extends BaseTrack {
- type: "audio";
- elements: AudioElement[];
- muted: boolean;
-}
-
-export interface GraphicTrack extends BaseTrack {
- type: "graphic";
- elements: (StickerElement | GraphicElement)[];
- hidden: boolean;
-}
-
-export interface EffectTrack extends BaseTrack {
- type: "effect";
- elements: EffectElement[];
- hidden: boolean;
-}
-
-export type TimelineTrack =
- | VideoTrack
- | TextTrack
- | AudioTrack
- | GraphicTrack
- | EffectTrack;
-
-export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
-
-export interface SceneTracks {
- overlay: OverlayTrack[];
- main: VideoTrack;
- audio: AudioTrack[];
-}
-
-export interface RetimeConfig {
- rate: number;
- maintainPitch?: boolean;
-}
-
-interface BaseAudioElement extends BaseTimelineElement {
- type: "audio";
- volume: number;
- muted?: boolean;
- buffer?: AudioBuffer;
- retime?: RetimeConfig;
-}
-
-export interface UploadAudioElement extends BaseAudioElement {
- sourceType: "upload";
- mediaId: string;
-}
-
-export interface LibraryAudioElement extends BaseAudioElement {
- sourceType: "library";
- sourceUrl: string;
-}
-
-export type AudioElement = UploadAudioElement | LibraryAudioElement;
-
-interface BaseTimelineElement {
- id: string;
- name: string;
- duration: MediaTime;
- startTime: MediaTime;
- trimStart: MediaTime;
- trimEnd: MediaTime;
- sourceDuration?: MediaTime;
- animations?: ElementAnimations;
-}
-
-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[];
-}
-
-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[];
-}
-
-export interface StickerElement extends BaseTimelineElement {
- type: "sticker";
- stickerId: string;
- /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
- intrinsicWidth?: number;
- intrinsicHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface GraphicElement extends BaseTimelineElement {
- type: "graphic";
- definitionId: string;
- params: ParamValues;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface EffectElement extends BaseTimelineElement {
- type: "effect";
- effectType: string;
- params: ParamValues;
-}
-
-export type ElementUpdatePatch =
- | { transform: Transform }
- | { opacity: number }
- | { volume: number };
-
-export type TimelineElement =
- | AudioElement
- | VideoElement
- | ImageElement
- | TextElement
- | StickerElement
- | GraphicElement
- | EffectElement;
-
-export type ElementType = TimelineElement["type"];
-
-function elementTypes<T extends ElementType[]>(...types: T): T {
- return types;
-}
-
-export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
-
-export type MaskableElement = Extract<
- TimelineElement,
- { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
->;
-
-export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
-
-export type RetimableElement = Extract<
- TimelineElement,
- { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
->;
-
-export const VISUAL_ELEMENT_TYPES = elementTypes(
- "video",
- "image",
- "text",
- "sticker",
- "graphic",
-);
-
-export type VisualElement = Extract<
- TimelineElement,
- { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
->;
-
-export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
-export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
-export type CreateAudioElement =
- | CreateUploadAudioElement
- | CreateLibraryAudioElement;
-export type CreateVideoElement = Omit<VideoElement, "id">;
-export type CreateImageElement = Omit<ImageElement, "id">;
-export type CreateTextElement = Omit<TextElement, "id">;
-export type CreateStickerElement = Omit<StickerElement, "id">;
-export type CreateGraphicElement = Omit<GraphicElement, "id">;
-export type CreateEffectElement = Omit<EffectElement, "id">;
-export type CreateTimelineElement =
- | CreateAudioElement
- | CreateVideoElement
- | CreateImageElement
- | CreateTextElement
- | CreateStickerElement
- | 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, MediaTime>;
- readonly startMouseX: number;
- readonly startMouseY: number;
- readonly startElementTime: MediaTime;
- readonly clickOffsetTime: MediaTime;
- readonly currentTime: MediaTime;
- readonly currentMouseX: number;
- readonly currentMouseY: number;
- readonly dropTarget: DropTarget | null;
- };
-
-export interface DropTarget {
- trackIndex: number;
- isNewTrack: boolean;
- insertPosition: "above" | "below" | null;
- xPosition: MediaTime;
- targetElement: { elementId: string; trackId: string } | null;
-}
-
-export interface ComputeDropTargetParams {
- elementType: ElementType;
- mouseX: number;
- mouseY: number;
- tracks: SceneTracks;
- playheadTime: MediaTime;
- isExternalDrop: boolean;
- elementDuration: MediaTime;
- pixelsPerSecond: number;
- zoomLevel: number;
- verticalDragDirection?: "up" | "down" | null;
- startTimeOverride?: MediaTime;
- excludeElementId?: string;
- targetElementTypes?: string[];
-}
-
-export interface ClipboardItem {
- trackId: string;
- trackType: TrackType;
- element: CreateTimelineElement;
-}
+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;
+ elementId: string;
+};
+
+export interface Bookmark {
+ time: MediaTime;
+ note?: string;
+ color?: string;
+ duration?: MediaTime;
+}
+
+export interface TScene {
+ id: string;
+ name: string;
+ isMain: boolean;
+ tracks: SceneTracks;
+ bookmarks: Bookmark[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
+
+interface BaseTrack {
+ id: string;
+ name: string;
+}
+
+export interface VideoTrack extends BaseTrack {
+ type: "video";
+ elements: (VideoElement | ImageElement)[];
+ muted: boolean;
+ hidden: boolean;
+}
+
+export interface TextTrack extends BaseTrack {
+ type: "text";
+ elements: TextElement[];
+ hidden: boolean;
+}
+
+export interface AudioTrack extends BaseTrack {
+ type: "audio";
+ elements: AudioElement[];
+ muted: boolean;
+}
+
+export interface GraphicTrack extends BaseTrack {
+ type: "graphic";
+ elements: (StickerElement | GraphicElement)[];
+ hidden: boolean;
+}
+
+export interface EffectTrack extends BaseTrack {
+ type: "effect";
+ elements: EffectElement[];
+ hidden: boolean;
+}
+
+export type TimelineTrack =
+ | VideoTrack
+ | TextTrack
+ | AudioTrack
+ | GraphicTrack
+ | EffectTrack;
+
+export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
+
+export interface SceneTracks {
+ overlay: OverlayTrack[];
+ main: VideoTrack;
+ audio: AudioTrack[];
+}
+
+export interface RetimeConfig {
+ rate: number;
+ maintainPitch?: boolean;
+}
+
+interface BaseAudioElement extends BaseTimelineElement {
+ type: "audio";
+ volume: number;
+ muted?: boolean;
+ buffer?: AudioBuffer;
+ retime?: RetimeConfig;
+}
+
+export interface UploadAudioElement extends BaseAudioElement {
+ sourceType: "upload";
+ mediaId: string;
+}
+
+export interface LibraryAudioElement extends BaseAudioElement {
+ sourceType: "library";
+ sourceUrl: string;
+}
+
+export type AudioElement = UploadAudioElement | LibraryAudioElement;
+
+interface BaseTimelineElement {
+ id: string;
+ name: string;
+ duration: MediaTime;
+ startTime: MediaTime;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+ sourceDuration?: MediaTime;
+ animations?: ElementAnimations;
+}
+
+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[];
+}
+
+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[];
+}
+
+export interface StickerElement extends BaseTimelineElement {
+ type: "sticker";
+ stickerId: string;
+ /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface GraphicElement extends BaseTimelineElement {
+ type: "graphic";
+ definitionId: string;
+ params: ParamValues;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface EffectElement extends BaseTimelineElement {
+ type: "effect";
+ effectType: string;
+ params: ParamValues;
+}
+
+export type ElementUpdatePatch =
+ | { transform: Transform }
+ | { opacity: number }
+ | { volume: number };
+
+export type TimelineElement =
+ | AudioElement
+ | VideoElement
+ | ImageElement
+ | TextElement
+ | StickerElement
+ | GraphicElement
+ | EffectElement;
+
+export type ElementType = TimelineElement["type"];
+
+function elementTypes<T extends ElementType[]>(...types: T): T {
+ return types;
+}
+
+export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
+
+export type MaskableElement = Extract<
+ TimelineElement,
+ { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
+
+export type RetimableElement = Extract<
+ TimelineElement,
+ { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const VISUAL_ELEMENT_TYPES = elementTypes(
+ "video",
+ "image",
+ "text",
+ "sticker",
+ "graphic",
+);
+
+export type VisualElement = Extract<
+ TimelineElement,
+ { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
+>;
+
+export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
+export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
+export type CreateAudioElement =
+ | CreateUploadAudioElement
+ | CreateLibraryAudioElement;
+export type CreateVideoElement = Omit<VideoElement, "id">;
+export type CreateImageElement = Omit<ImageElement, "id">;
+export type CreateTextElement = Omit<TextElement, "id">;
+export type CreateStickerElement = Omit<StickerElement, "id">;
+export type CreateGraphicElement = Omit<GraphicElement, "id">;
+export type CreateEffectElement = Omit<EffectElement, "id">;
+export type CreateTimelineElement =
+ | CreateAudioElement
+ | CreateVideoElement
+ | CreateImageElement
+ | CreateTextElement
+ | CreateStickerElement
+ | 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, MediaTime>;
+ readonly startMouseX: number;
+ readonly startMouseY: number;
+ readonly startElementTime: MediaTime;
+ readonly clickOffsetTime: MediaTime;
+ readonly currentTime: MediaTime;
+ readonly currentMouseX: number;
+ readonly currentMouseY: number;
+ readonly dropTarget: DropTarget | null;
+ };
+
+export interface DropTarget {
+ trackIndex: number;
+ isNewTrack: boolean;
+ insertPosition: "above" | "below" | null;
+ xPosition: MediaTime;
+ targetElement: { elementId: string; trackId: string } | null;
+}
+
+export interface ComputeDropTargetParams {
+ elementType: ElementType;
+ mouseX: number;
+ mouseY: number;
+ tracks: SceneTracks;
+ playheadTime: MediaTime;
+ isExternalDrop: boolean;
+ elementDuration: MediaTime;
+ pixelsPerSecond: number;
+ zoomLevel: number;
+ verticalDragDirection?: "up" | "down" | null;
+ startTimeOverride?: MediaTime;
+ excludeElementId?: string;
+ targetElementTypes?: string[];
+}
+
+export interface ClipboardItem {
+ trackId: string;
+ trackType: TrackType;
+ element: CreateTimelineElement;
+}
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/media-time.ts b/apps/web/src/wasm/media-time.ts
index 82f5378d..0ec23cf3 100644
--- a/apps/web/src/wasm/media-time.ts
+++ b/apps/web/src/wasm/media-time.ts
@@ -26,20 +26,37 @@ export type MediaTime = number & { readonly __mediaTime: unique symbol };
export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
-export const ZERO_MEDIA_TIME = 0 as MediaTime;
+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. Asserts in dev that
- * the value is actually an integer; cast in release. Use `roundMediaTime` when
- * the input may be fractional.
+ * Construct a `MediaTime` from a known-integer tick count. Use `roundMediaTime`
+ * when the input may be fractional.
*/
export function mediaTime({ ticks }: { ticks: number }): MediaTime {
- if (process.env.NODE_ENV !== "production" && !Number.isInteger(ticks)) {
- throw new Error(
- `mediaTime() requires an integer tick count, got ${ticks}. Use roundMediaTime() for fractional values.`,
- );
- }
- return ticks as MediaTime;
+ return requireMediaTime({
+ value: ticks,
+ context: "mediaTime()",
+ });
}
/**
@@ -52,9 +69,12 @@ export function mediaTime({ ticks }: { ticks: number }): MediaTime {
export function roundMediaTime({ time }: { time: number }): MediaTime {
const roundedMagnitude = Math.round(Math.abs(time));
if (roundedMagnitude === 0) {
- return 0 as MediaTime;
+ return ZERO_MEDIA_TIME;
}
- return (time < 0 ? -roundedMagnitude : roundedMagnitude) as MediaTime;
+ return requireMediaTime({
+ value: time < 0 ? -roundedMagnitude : roundedMagnitude,
+ context: "roundMediaTime()",
+ });
}
export function mediaTimeFromSeconds({
@@ -68,7 +88,10 @@ export function mediaTimeFromSeconds({
`mediaTimeFromSeconds: rust returned undefined for seconds=${seconds}`,
);
}
- return result as MediaTime;
+ return requireMediaTime({
+ value: result,
+ context: "mediaTimeFromSeconds()",
+ });
}
export function mediaTimeToSeconds({ time }: { time: MediaTime }): number {
@@ -76,9 +99,7 @@ export function mediaTimeToSeconds({ time }: { time: MediaTime }): number {
}
/**
- * Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too;
- * the cast is a no-op at runtime, only re-establishes the brand for the type
- * system.
+ * Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too.
*/
export function addMediaTime({
a,
@@ -87,7 +108,10 @@ export function addMediaTime({
a: MediaTime;
b: MediaTime;
}): MediaTime {
- return (a + b) as MediaTime;
+ return requireMediaTime({
+ value: a + b,
+ context: "addMediaTime()",
+ });
}
export function subMediaTime({
@@ -97,7 +121,10 @@ export function subMediaTime({
a: MediaTime;
b: MediaTime;
}): MediaTime {
- return (a - b) as MediaTime;
+ return requireMediaTime({
+ value: a - b,
+ context: "subMediaTime()",
+ });
}
export function maxMediaTime({
@@ -107,7 +134,7 @@ export function maxMediaTime({
a: MediaTime;
b: MediaTime;
}): MediaTime {
- return (a > b ? a : b) as MediaTime;
+ return a > b ? a : b;
}
export function minMediaTime({
@@ -117,7 +144,7 @@ export function minMediaTime({
a: MediaTime;
b: MediaTime;
}): MediaTime {
- return (a < b ? a : b) as MediaTime;
+ return a < b ? a : b;
}
export function clampMediaTime({
@@ -141,7 +168,10 @@ export function roundFrameTime({
time: MediaTime;
fps: FrameRate;
}): MediaTime {
- return (_roundToFrame({ time, rate: fps }) ?? time) as MediaTime;
+ return requireMediaTime({
+ value: _roundToFrame({ time, rate: fps }) ?? time,
+ context: "roundFrameTime()",
+ });
}
export function roundFrameTicks({
@@ -163,7 +193,10 @@ export function snapSeekMediaTime({
duration: MediaTime;
fps: FrameRate;
}): MediaTime {
- return (_snappedSeekTime({ time, duration, rate: fps }) ?? time) as MediaTime;
+ return requireMediaTime({
+ value: _snappedSeekTime({ time, duration, rate: fps }) ?? time,
+ context: "snapSeekMediaTime()",
+ });
}
export function lastFrameMediaTime({
@@ -173,7 +206,10 @@ export function lastFrameMediaTime({
duration: MediaTime;
fps: FrameRate;
}): MediaTime {
- return (_lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime;
+ return requireMediaTime({
+ value: _lastFrameTime({ duration, rate: fps }) ?? duration,
+ context: "lastFrameMediaTime()",
+ });
}
export function parseMediaTimecode({
@@ -186,5 +222,11 @@ export function parseMediaTimecode({
fps: FrameRate;
}): MediaTime | null {
const parsedTime = _parseTimecode({ timeCode, format, rate: fps });
- return parsedTime == null ? null : (parsedTime as MediaTime);
+ if (parsedTime == null) {
+ return null;
+ }
+ return requireMediaTime({
+ value: parsedTime,
+ context: "parseMediaTimecode()",
+ });
}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 3ac10911..3573338a 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -1,39 +1,39 @@
-{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./src/*"],
- "content-collections": ["./.content-collections/generated"]
- },
- "forceConsistentCasingInFileNames": true
- },
- "include": [
- "**/*.ts",
- "**/*.tsx",
- "apps/web/.next/types/**/*.ts",
- "next-env.d.ts",
- ".next/types/**/*.ts",
- "src/types/**/*.d.ts",
- "../../use-frame-cache.ts",
- "src/stores/timeline-store.ts",
- ".next/dev/types/**/*.ts"
- ],
- "exclude": ["node_modules"]
-}
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"],
+ "content-collections": ["./.content-collections/generated"]
+ },
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ "apps/web/.next/types/**/*.ts",
+ "next-env.d.ts",
+ ".next/types/**/*.ts",
+ "src/types/**/*.d.ts",
+ "../../use-frame-cache.ts",
+ "src/stores/timeline-store.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}