diff options
Diffstat (limited to 'apps/web/src')
268 files changed, 8984 insertions, 7421 deletions
diff --git a/apps/web/src/actions/components/shortcuts-dialog.tsx b/apps/web/src/actions/components/shortcuts-dialog.tsx index cd9c0242..7f7d8a87 100644 --- a/apps/web/src/actions/components/shortcuts-dialog.tsx +++ b/apps/web/src/actions/components/shortcuts-dialog.tsx @@ -51,10 +51,10 @@ export function ShortcutsDialog({ const keyString = getKeybindingString(e);
if (keyString) {
- const conflict = validateKeybinding(
- keyString,
- recordingShortcut.action,
- );
+ const conflict = validateKeybinding({
+ key: keyString,
+ action: recordingShortcut.action,
+ });
if (conflict) {
toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
@@ -68,7 +68,10 @@ export function ShortcutsDialog({ removeKeybinding(key);
}
- updateKeybinding(keyString, recordingShortcut.action);
+ updateKeybinding({
+ key: keyString,
+ action: recordingShortcut.action,
+ });
setIsRecording(false);
setRecordingShortcut(null);
diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts index 6f0ba700..986510ec 100644 --- a/apps/web/src/actions/definitions.ts +++ b/apps/web/src/actions/definitions.ts @@ -1,7 +1,4 @@ -import type { - KeybindingConfig, - ShortcutKey, -} from "@/actions/keybinding"; +import type { ShortcutKey } from "@/actions/keybinding"; import type { TActionWithOptionalArgs } from "./types"; export type TActionCategory = @@ -155,33 +152,36 @@ export const ACTIONS = { export type TAction = keyof typeof ACTIONS; -const ACTION_DEFAULT_SHORTCUTS = { - "toggle-play": ["space", "k"], - "seek-forward": ["l"], - "seek-backward": ["j"], - "frame-step-forward": ["right"], - "frame-step-backward": ["left"], - "jump-forward": ["shift+right"], - "jump-backward": ["shift+left"], - "goto-start": ["home", "enter"], - "goto-end": ["end"], - split: ["s"], - "split-left": ["q"], - "split-right": ["w"], - "delete-selected": ["backspace", "delete"], - "copy-selected": ["ctrl+c"], - "paste-copied": ["ctrl+v"], - "toggle-snapping": ["n"], - "select-all": ["ctrl+a"], - "cancel-interaction": ["escape"], - "duplicate-selected": ["ctrl+d"], - undo: ["ctrl+z"], - redo: ["ctrl+shift+z", "ctrl+y"], -} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>; +const ACTION_DEFAULT_SHORTCUTS = [ + ["toggle-play", ["space", "k"]], + ["seek-forward", ["l"]], + ["seek-backward", ["j"]], + ["frame-step-forward", ["right"]], + ["frame-step-backward", ["left"]], + ["jump-forward", ["shift+right"]], + ["jump-backward", ["shift+left"]], + ["goto-start", ["home", "enter"]], + ["goto-end", ["end"]], + ["split", ["s"]], + ["split-left", ["q"]], + ["split-right", ["w"]], + ["delete-selected", ["backspace", "delete"]], + ["copy-selected", ["ctrl+c"]], + ["paste-copied", ["ctrl+v"]], + ["toggle-snapping", ["n"]], + ["select-all", ["ctrl+a"]], + ["cancel-interaction", ["escape"]], + ["duplicate-selected", ["ctrl+d"]], + ["undo", ["ctrl+z"]], + ["redo", ["ctrl+shift+z", "ctrl+y"]], +] as const satisfies ReadonlyArray< + readonly [TActionWithOptionalArgs, readonly ShortcutKey[]] +>; -const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< - Record<TAction, readonly ShortcutKey[]> -> = ACTION_DEFAULT_SHORTCUTS; +const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map< + TAction, + readonly ShortcutKey[] +>(ACTION_DEFAULT_SHORTCUTS); export function getActionDefinition({ action, @@ -190,18 +190,19 @@ export function getActionDefinition({ }): TActionDefinition { return { ...ACTIONS[action], - defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action], + defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action), }; } -export function getDefaultShortcuts(): KeybindingConfig { - const shortcuts: KeybindingConfig = {}; +export function getDefaultShortcuts(): Map< + ShortcutKey, + TActionWithOptionalArgs +> { + const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>(); - for (const [action, defaultShortcuts] of Object.entries( - ACTION_DEFAULT_SHORTCUTS, - ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) { + for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) { for (const shortcut of defaultShortcuts) { - shortcuts[shortcut] = action; + shortcuts.set(shortcut, action); } } diff --git a/apps/web/src/actions/keybinding.ts b/apps/web/src/actions/keybinding.ts index 65ef4538..aeec2fd6 100644 --- a/apps/web/src/actions/keybinding.ts +++ b/apps/web/src/actions/keybinding.ts @@ -1,79 +1,43 @@ -import type { TActionWithOptionalArgs } from "./types";
-
-/**
- * Alt is also regarded as macOS OPTION (⌥) key
- * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
- */
-export type ModifierKeys =
- | "ctrl"
- | "alt"
- | "shift"
- | "ctrl+shift"
- | "alt+shift"
- | "ctrl+alt"
- | "ctrl+alt+shift";
-
-export type Key =
- | "a"
- | "b"
- | "c"
- | "d"
- | "e"
- | "f"
- | "g"
- | "h"
- | "i"
- | "j"
- | "k"
- | "l"
- | "m"
- | "n"
- | "o"
- | "p"
- | "q"
- | "r"
- | "s"
- | "t"
- | "u"
- | "v"
- | "w"
- | "x"
- | "y"
- | "z"
- | "0"
- | "1"
- | "2"
- | "3"
- | "4"
- | "5"
- | "6"
- | "7"
- | "8"
- | "9"
- | "up"
- | "down"
- | "left"
- | "right"
- | "/"
- | "?"
- | "."
- | "enter"
- | "tab"
- | "space"
- | "escape"
- | "esc"
- | "backspace"
- | "delete"
- | "home"
- | "end";
-/* eslint-enable */
-
-export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
-// Singular keybindings (these will be disabled when an input-ish area has been focused)
-export type SingleCharacterShortcutKey = `${Key}`;
-
-export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
-
-export type KeybindingConfig = {
- [key in ShortcutKey]?: TActionWithOptionalArgs;
-};
+import type { TActionWithOptionalArgs } from "./types"; + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +export type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift"; + +const KEYS = [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", "z", + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + "up", "down", "left", "right", + "/", "?", ".", + "enter", "tab", "space", "escape", "esc", + "backspace", "delete", "home", "end", +] as const; + +export type Key = (typeof KEYS)[number]; + +const KEY_SET: ReadonlySet<string> = new Set(KEYS); + +export function isKey(value: string): value is Key { + return KEY_SET.has(value); +} + +export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; +// Singular keybindings (these will be disabled when an input-ish area has been focused) +export type SingleCharacterShortcutKey = `${Key}`; + +export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; + +export type KeybindingConfig = { + [key in ShortcutKey]?: TActionWithOptionalArgs; +}; diff --git a/apps/web/src/actions/keybindings-store.ts b/apps/web/src/actions/keybindings-store.ts index c0a1a5e2..8f344487 100644 --- a/apps/web/src/actions/keybindings-store.ts +++ b/apps/web/src/actions/keybindings-store.ts @@ -6,11 +6,15 @@ import type { TActionWithOptionalArgs } from "@/actions"; import { getDefaultShortcuts } from "@/actions"; import { isTypableDOMElement } from "@/utils/browser"; import { isAppleDevice } from "@/utils/platform"; -import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; +import type { + Key, + KeybindingConfig, + ModifierKeys, + ShortcutKey, +} from "@/actions/keybinding"; +import { isKey } from "@/actions/keybinding"; import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; -const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); - export interface KeybindingConflict { key: ShortcutKey; existingAction: TActionWithOptionalArgs; @@ -18,38 +22,57 @@ export interface KeybindingConflict { } interface KeybindingsState { - keybindings: KeybindingConfig; + keybindings: Map<ShortcutKey, TActionWithOptionalArgs>; isCustomized: boolean; overlayDepth: number; openOverlayIds: string[]; isLoadingProject: boolean; isRecording: boolean; - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; + updateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => void; removeKeybinding: (key: ShortcutKey) => void; resetToDefaults: () => void; importKeybindings: (config: KeybindingConfig) => void; - exportKeybindings: () => KeybindingConfig; + exportKeybindings: () => Record<string, TActionWithOptionalArgs>; openOverlay: (overlayId: string) => void; closeOverlay: (overlayId: string) => void; setLoadingProject: (loading: boolean) => void; setIsRecording: (isRecording: boolean) => void; - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => KeybindingConflict | null; + validateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => KeybindingConflict | null; getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; } +type PersistedState = { + keybindings: Record<string, TActionWithOptionalArgs>; + isCustomized: boolean; +}; + function isDOMElement(element: EventTarget | null): element is HTMLElement { return element instanceof HTMLElement; } +function isPersistedState(value: unknown): value is PersistedState { + if (!value || typeof value !== "object") return false; + if (!("keybindings" in value) || !("isCustomized" in value)) return false; + const { keybindings, isCustomized } = value; + return ( + typeof keybindings === "object" && + keybindings !== null && + typeof isCustomized === "boolean" + ); +} + export const useKeybindingsStore = create<KeybindingsState>()( persist( (set, get) => ({ - keybindings: { ...defaultKeybindings }, + keybindings: getDefaultShortcuts(), isCustomized: false, overlayDepth: 0, openOverlayIds: [], @@ -61,10 +84,9 @@ export const useKeybindingsStore = create<KeybindingsState>()( const openOverlayIds = s.openOverlayIds.includes(overlayId) ? s.openOverlayIds : [...s.openOverlayIds, overlayId]; - const nextOverlayDepth = openOverlayIds.length; return { openOverlayIds, - overlayDepth: nextOverlayDepth, + overlayDepth: openOverlayIds.length, }; }), closeOverlay: (overlayId) => @@ -72,35 +94,32 @@ export const useKeybindingsStore = create<KeybindingsState>()( const openOverlayIds = s.openOverlayIds.filter( (id) => id !== overlayId, ); - const nextOverlayDepth = openOverlayIds.length; return { openOverlayIds, - overlayDepth: nextOverlayDepth, + overlayDepth: openOverlayIds.length, }; }), setLoadingProject: (loading) => { set({ isLoadingProject: loading }); }, - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { + updateKeybinding: ({ key, action }) => { set((state) => { - const newKeybindings = { ...state.keybindings }; - newKeybindings[key] = action; - + const next = new Map(state.keybindings); + next.set(key, action); return { - keybindings: newKeybindings, + keybindings: next, isCustomized: true, }; }); }, - removeKeybinding: (key: ShortcutKey) => { + removeKeybinding: (key) => { set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; - + const next = new Map(state.keybindings); + next.delete(key); return { - keybindings: newKeybindings, + keybindings: next, isCustomized: true, }; }); @@ -108,34 +127,35 @@ export const useKeybindingsStore = create<KeybindingsState>()( resetToDefaults: () => { set({ - keybindings: { ...defaultKeybindings }, + keybindings: getDefaultShortcuts(), isCustomized: false, }); }, - importKeybindings: (config: KeybindingConfig) => { - for (const [key] of Object.entries(config)) { + importKeybindings: (config) => { + const next = new Map<ShortcutKey, TActionWithOptionalArgs>(); + for (const [key, action] of Object.entries(config)) { if (typeof key !== "string" || key.length === 0) { throw new Error(`Invalid key format: ${key}`); } + if (action !== undefined) { + // Public type's keys are `ShortcutKey`; trust the caller's typing. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + next.set(key as ShortcutKey, action); + } } set({ - keybindings: { ...config }, + keybindings: next, isCustomized: true, }); }, exportKeybindings: () => { - return get().keybindings; + return Object.fromEntries(get().keybindings); }, - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; - + validateKeybinding: ({ key, action }) => { + const existingAction = get().keybindings.get(key); if (existingAction && existingAction !== action) { return { key, @@ -143,33 +163,45 @@ export const useKeybindingsStore = create<KeybindingsState>()( newAction: action, }; } - return null; }, - setIsRecording: (isRecording: boolean) => { + setIsRecording: (isRecording) => { set({ isRecording }); }, - getKeybindingsForAction: (action: TActionWithOptionalArgs) => { - const { keybindings } = get(); - return Object.keys(keybindings).filter( - (key) => keybindings[key as ShortcutKey] === action, - ) as ShortcutKey[]; + getKeybindingsForAction: (action) => { + const result: ShortcutKey[] = []; + for (const [key, mapped] of get().keybindings) { + if (mapped === action) result.push(key); + } + return result; }, - getKeybindingString: (ev: KeyboardEvent) => { - return generateKeybindingString(ev) as ShortcutKey | null; - }, + getKeybindingString: (ev) => generateKeybindingString(ev), }), { name: "opencut-keybindings", version: CURRENT_VERSION, - partialize: (state) => ({ - keybindings: state.keybindings, + partialize: (state): PersistedState => ({ + keybindings: Object.fromEntries(state.keybindings), isCustomized: state.isCustomized, }), migrate: (persisted, version) => runMigrations({ state: persisted, fromVersion: version }), + merge: (persisted, current) => { + if (!isPersistedState(persisted)) return current; + const entries = Object.entries(persisted.keybindings); + // Persistence boundary: keys are normalized by the migration chain. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const typedEntries = entries as Array< + [ShortcutKey, TActionWithOptionalArgs] + >; + return { + ...current, + keybindings: new Map(typedEntries), + isCustomized: persisted.isCustomized, + }; + }, }, ), ); @@ -184,68 +216,59 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { if ( modifierKey === "shift" && isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) + isTypableDOMElement({ element: target }) ) { return null; } - return `${modifierKey}+${key}` as ShortcutKey; + return `${modifierKey}+${key}`; } - if ( - isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) - ) + if (isDOMElement(target) && isTypableDOMElement({ element: target })) { return null; + } - return `${key}` as ShortcutKey; + return key; } -function getPressedKey(ev: KeyboardEvent): string | null { - const key = (ev.key ?? "").toLowerCase(); +function getPressedKey(ev: KeyboardEvent): Key | null { + const raw = (ev.key ?? "").toLowerCase(); const code = ev.code ?? ""; - if (code === "Space" || key === " " || key === "spacebar" || key === "space") + if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space") return "space"; - if (key.startsWith("arrow")) return key.slice(5); - - if (key === "escape") return "escape"; - if (key === "tab") return "tab"; - if (key === "home") return "home"; - if (key === "end") return "end"; - if (key === "delete") return "delete"; - if (key === "backspace") return "backspace"; + if (raw === "arrowup") return "up"; + if (raw === "arrowdown") return "down"; + if (raw === "arrowleft") return "left"; + if (raw === "arrowright") return "right"; if (code.startsWith("Key")) { const letter = code.slice(3).toLowerCase(); - if (letter.length === 1 && letter >= "a" && letter <= "z") return letter; + if (isKey(letter)) return letter; } - // Use physical key position for AZERTY and other non-QWERTY layouts + // Use physical key position for AZERTY and other non-QWERTY layouts. if (code.startsWith("Digit")) { const digit = code.slice(5); - if (digit.length === 1 && digit >= "0" && digit <= "9") return digit; + if (isKey(digit)) return digit; } - const isDigit = key.length === 1 && key >= "0" && key <= "9"; - if (isDigit) return key; - - if (key === "/" || key === "." || key === "enter") return key; - + if (isKey(raw)) return raw; return null; } -function getActiveModifier(ev: KeyboardEvent): string | null { - const modifierKeys = { - ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, - alt: ev.altKey, - shift: ev.shiftKey, - }; +function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null { + const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey; + const alt = ev.altKey; + const shift = ev.shiftKey; - const activeModifier = Object.keys(modifierKeys) - .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) - .join("+"); - - return activeModifier === "" ? null : activeModifier; + if (ctrl && alt && shift) return "ctrl+alt+shift"; + if (ctrl && alt) return "ctrl+alt"; + if (ctrl && shift) return "ctrl+shift"; + if (alt && shift) return "alt+shift"; + if (ctrl) return "ctrl"; + if (alt) return "alt"; + if (shift) return "shift"; + return null; } diff --git a/apps/web/src/actions/keybindings/__tests__/persistence.test.ts b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts new file mode 100644 index 00000000..bcccc6a4 --- /dev/null +++ b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts @@ -0,0 +1,144 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { + decodePersistedKeybindingsState, + migratePersistedKeybindingsState, + parseImportedKeybindings, + serializeKeybindingsState, +} from "../persistence"; + +describe("keybinding persistence", () => { + let warnSpy: ReturnType<typeof mock>; + let originalWarn: typeof console.warn; + + beforeEach(() => { + originalWarn = console.warn; + warnSpy = mock(() => {}); + console.warn = warnSpy; + }); + + afterEach(() => { + console.warn = originalWarn; + }); + + test("migrates legacy persisted keybindings before decoding them", () => { + const migrated = migratePersistedKeybindingsState({ + state: { + keybindings: { + s: "split-selected", + "ctrl+v": "paste-selected", + }, + isCustomized: true, + }, + fromVersion: 2, + }); + + const decoded = decodePersistedKeybindingsState({ state: migrated }); + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected migrated keybindings to decode"); + + expect(decoded.isCustomized).toBe(true); + expect(decoded.keybindings.get("s")).toBe("split"); + expect(decoded.keybindings.get("ctrl+v")).toBe("paste-copied"); + expect(decoded.keybindings.get("escape")).toBe("cancel-interaction"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("filters invalid persisted entries at the boundary and warns", () => { + const decoded = decodePersistedKeybindingsState({ + state: { + keybindings: { + space: "toggle-play", + "shift+bogus": "toggle-play", + "ctrl+v": "not-an-action", + }, + isCustomized: false, + }, + }); + + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected persisted keybindings to decode"); + + expect(Array.from(decoded.keybindings.entries())).toEqual([ + ["space", "toggle-play"], + ]); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test("returns null and warns when persisted shape is unrecognizable", () => { + const decoded = decodePersistedKeybindingsState({ state: "garbage" }); + expect(decoded).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test("round-trips actions that have no default shortcut", () => { + // `stop-playback` is a valid `TActionWithOptionalArgs` but is not in the + // defaults table; the validator must still accept it. + const serialized = serializeKeybindingsState({ + keybindings: new Map([ + ["space", "toggle-play"], + ["x", "stop-playback"], + ["b", "toggle-bookmark"], + ]), + isCustomized: true, + }); + + const decoded = decodePersistedKeybindingsState({ state: serialized }); + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected round-tripped keybindings"); + + expect(decoded.keybindings.get("space")).toBe("toggle-play"); + expect(decoded.keybindings.get("x")).toBe("stop-playback"); + expect(decoded.keybindings.get("b")).toBe("toggle-bookmark"); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe("parseImportedKeybindings", () => { + test("accepts a valid configuration", () => { + const result = parseImportedKeybindings({ + config: { + space: "toggle-play", + x: "stop-playback", + }, + }); + + expect(result.get("space")).toBe("toggle-play"); + expect(result.get("x")).toBe("stop-playback"); + }); + + test("throws on non-object input", () => { + expect(() => parseImportedKeybindings({ config: null })).toThrow( + /JSON object/, + ); + expect(() => parseImportedKeybindings({ config: [] })).toThrow( + /JSON object/, + ); + }); + + test("throws on non-string action values", () => { + expect(() => + parseImportedKeybindings({ config: { space: 42 } }), + ).toThrow(/expected string/); + }); + + test("throws on invalid shortcut keys", () => { + expect(() => + parseImportedKeybindings({ + config: { "shift+bogus": "toggle-play" }, + }), + ).toThrow(/shift\+bogus/); + }); + + test("throws on invalid actions", () => { + expect(() => + parseImportedKeybindings({ config: { space: "not-an-action" } }), + ).toThrow(/not-an-action/); + }); +}); diff --git a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts index 0171c5a5..81fd86a7 100644 --- a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts +++ b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts @@ -1,13 +1,8 @@ -import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
-import type { TActionWithOptionalArgs } from "@/actions";
-
-interface V2State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v2ToV3({ state }: { state: unknown }): unknown {
- const v2 = state as V2State;
+ const v2 = getPersistedKeybindingsState({ state });
+ if (!v2) return state;
const renames: Record<string, string> = {
"split-selected": "split",
@@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown { const migrated = { ...v2.keybindings };
for (const [key, action] of Object.entries(migrated)) {
- if (action && renames[action]) {
- migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ const renamedAction = action ? renames[action] : undefined;
+ if (renamedAction) {
+ migrated[key] = renamedAction;
}
}
diff --git a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts index 8541071b..3724f61c 100644 --- a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts +++ b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts @@ -1,14 +1,8 @@ -import type { TActionWithOptionalArgs } from "@/actions";
-import type { ShortcutKey } from "@/actions/keybinding";
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V3State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v3ToV4({ state }: { state: unknown }): unknown {
- const v3 = state as V3State;
+ const v3 = getPersistedKeybindingsState({ state });
+ if (!v3) return state;
const renames: Record<string, string> = {
"paste-selected": "paste-copied",
@@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown { const migrated = { ...v3.keybindings };
for (const [key, action] of Object.entries(migrated)) {
- if (action && renames[action]) {
- migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ const renamedAction = action ? renames[action] : undefined;
+ if (renamedAction) {
+ migrated[key] = renamedAction;
}
}
diff --git a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts index f6937c39..bb60b044 100644 --- a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts +++ b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts @@ -1,12 +1,8 @@ -import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V4State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v4ToV5({ state }: { state: unknown }): unknown {
- const v4 = state as V4State;
+ const v4 = getPersistedKeybindingsState({ state });
+ if (!v4) return state;
const keybindings = { ...v4.keybindings };
if (!keybindings.escape) {
diff --git a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts index 09b44307..cb572aac 100644 --- a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts +++ b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts @@ -1,12 +1,8 @@ -import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V5State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v5ToV6({ state }: { state: unknown }): unknown {
- const v5 = state as V5State;
+ const v5 = getPersistedKeybindingsState({ state });
+ if (!v5) return state;
const keybindings = { ...v5.keybindings };
if (keybindings.escape === "deselect-all") {
diff --git a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts index 46a99ac1..9c05db23 100644 --- a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts +++ b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts @@ -1,16 +1,12 @@ -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V6State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v6ToV7({ state }: { state: unknown }): unknown { - const v6 = state as V6State; + const v6 = getPersistedKeybindingsState({ state }); + if (!v6) return state; const keybindings = { ...v6.keybindings }; - for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) { - if (keybindings[key] === ("split-element" as never)) { + for (const [key, action] of Object.entries(keybindings)) { + if (action === "split-element") { keybindings[key] = "split"; } } diff --git a/apps/web/src/actions/keybindings/persisted-state.ts b/apps/web/src/actions/keybindings/persisted-state.ts new file mode 100644 index 00000000..57d83f6d --- /dev/null +++ b/apps/web/src/actions/keybindings/persisted-state.ts @@ -0,0 +1,37 @@ +export type PersistedKeybindingConfig = Record<string, string | undefined>; + +export interface PersistedKeybindingsState { + keybindings: PersistedKeybindingConfig; + isCustomized: boolean; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function getPersistedKeybindingsState({ + state, +}: { + state: unknown; +}): PersistedKeybindingsState | null { + if (!isRecord(state)) return null; + + const { keybindings, isCustomized } = state; + if (!isRecord(keybindings) || typeof isCustomized !== "boolean") { + return null; + } + + const normalizedKeybindings: PersistedKeybindingConfig = {}; + for (const [key, action] of Object.entries(keybindings)) { + if (action !== undefined && typeof action !== "string") { + return null; + } + + normalizedKeybindings[key] = action; + } + + return { + keybindings: normalizedKeybindings, + isCustomized, + }; +} diff --git a/apps/web/src/actions/keybindings/persistence.ts b/apps/web/src/actions/keybindings/persistence.ts new file mode 100644 index 00000000..ced44bd0 --- /dev/null +++ b/apps/web/src/actions/keybindings/persistence.ts @@ -0,0 +1,119 @@ +import type { ShortcutKey } from "@/actions/keybinding"; +import { isShortcutKey } from "@/actions/keybinding"; +import type { TActionWithOptionalArgs } from "@/actions"; +import { isActionWithOptionalArgs } from "@/actions"; +import { runMigrations } from "./migrations"; +import { + getPersistedKeybindingsState, + type PersistedKeybindingsState, +} from "./persisted-state"; + +export interface DecodedKeybindingsState { + keybindings: Map<ShortcutKey, TActionWithOptionalArgs>; + isCustomized: boolean; +} + +export function serializeKeybindingsState({ + keybindings, + isCustomized, +}: DecodedKeybindingsState): PersistedKeybindingsState { + return { + keybindings: Object.fromEntries(keybindings), + isCustomized, + }; +} + +export function migratePersistedKeybindingsState({ + state, + fromVersion, +}: { + state: unknown; + fromVersion: number; +}): unknown { + return runMigrations({ state, fromVersion }); +} + +/** + * Decode a persisted/migrated keybindings blob into the in-memory shape. + * + * Lossy by design: invalid entries are dropped and a warning is emitted, so the + * user falls back to (mostly) sensible defaults instead of a broken store. + * Returns `null` if the top-level shape is unrecognizable, in which case the + * caller should keep its current state. + */ +export function decodePersistedKeybindingsState({ + state, +}: { + state: unknown; +}): DecodedKeybindingsState | null { + const persisted = getPersistedKeybindingsState({ state }); + if (!persisted) { + console.warn( + "[keybindings] Persisted state has unexpected shape; keeping current keybindings.", + state, + ); + return null; + } + + const keybindings = new Map<ShortcutKey, TActionWithOptionalArgs>(); + const dropped: Array<{ key: string; action: string | undefined }> = []; + for (const [key, action] of Object.entries(persisted.keybindings)) { + if (action === undefined) continue; + if (!isShortcutKey(key) || !isActionWithOptionalArgs(action)) { + dropped.push({ key, action }); + continue; + } + + keybindings.set(key, action); + } + + if (dropped.length > 0) { + console.warn( + "[keybindings] Dropped invalid persisted entries:", + dropped, + ); + } + + return { + keybindings, + isCustomized: persisted.isCustomized, + }; +} + +/** + * Parse a user-supplied keybindings configuration (typically the output of + * `JSON.parse` on an imported file). + * + * Strict by design: throws on the first invalid entry so the caller can surface + * the failure to the user instead of silently producing a half-applied import. + * Accepts `unknown` because the input has already crossed a trust boundary. + */ +export function parseImportedKeybindings({ + config, +}: { + config: unknown; +}): Map<ShortcutKey, TActionWithOptionalArgs> { + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw new Error("Imported keybindings must be a JSON object"); + } + + const result = new Map<ShortcutKey, TActionWithOptionalArgs>(); + for (const [key, action] of Object.entries(config)) { + if (action === undefined) continue; + if (typeof action !== "string") { + throw new Error( + `Invalid action for "${key}": expected string, got ${typeof action}`, + ); + } + if (!isShortcutKey(key)) { + throw new Error(`Invalid shortcut key: ${JSON.stringify(key)}`); + } + if (!isActionWithOptionalArgs(action)) { + throw new Error( + `Invalid action for "${key}": ${JSON.stringify(action)}`, + ); + } + result.set(key, action); + } + return result; +} diff --git a/apps/web/src/actions/registry.ts b/apps/web/src/actions/registry.ts index e69b3b9f..ece14eaf 100644 --- a/apps/web/src/actions/registry.ts +++ b/apps/web/src/actions/registry.ts @@ -11,6 +11,7 @@ import type { type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void; const boundActions: Partial<Record<TAction, ActionHandler[]>> = {}; +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). export function bindAction<A extends TAction>( action: A, handler: TActionFunc<A>, @@ -24,6 +25,7 @@ export function bindAction<A extends TAction>( } } +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). export function unbindAction<A extends TAction>( action: A, handler: TActionFunc<A>, @@ -52,6 +54,7 @@ type InvokeActionFunc = { ): void; }; +// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger. export const invokeAction: InvokeActionFunc = <A extends TAction>( action: A, args?: TArgOfAction<A>, diff --git a/apps/web/src/actions/types.ts b/apps/web/src/actions/types.ts index 41258c7b..38627b95 100644 --- a/apps/web/src/actions/types.ts +++ b/apps/web/src/actions/types.ts @@ -1,44 +1,44 @@ -import type { MutableRefObject } from "react";
-import type { TAction } from "./definitions";
-
-export type { TAction };
-
-export type TActionArgsMap = {
- "seek-forward": { seconds: number } | undefined;
- "seek-backward": { seconds: number } | undefined;
- "jump-forward": { seconds: number } | undefined;
- "jump-backward": { seconds: number } | undefined;
- "remove-media-asset": { projectId: string; assetId: string };
- "remove-media-assets": { projectId: string; assetIds: string[] };
-};
-
-type TKeysWithValueUndefined<T> = {
- [K in keyof T]: undefined extends T[K] ? K : never;
-}[keyof T];
-
-export type TActionWithArgs = keyof TActionArgsMap;
-
-export type TActionWithOptionalArgs =
- | TActionWithNoArgs
- | TKeysWithValueUndefined<TActionArgsMap>;
-
-export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
-
-export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
- ? TActionArgsMap[A]
- : undefined;
-
-export type TActionFunc<A extends TAction> = A extends TActionWithArgs
- ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
- : (_?: undefined, trigger?: TInvocationTrigger) => void;
-
-export type TInvocationTrigger = "keypress" | "mouseclick";
-
-export type TBoundActionList = {
- [A in TAction]?: Array<TActionFunc<A>>;
-};
-
-export type TActionHandlerOptions =
- | MutableRefObject<boolean>
- | boolean
- | undefined;
+import type { MutableRefObject } from "react"; +import type { TAction } from "./definitions"; + +export type { TAction }; + +export type TActionArgsMap = { + "seek-forward": { seconds: number } | undefined; + "seek-backward": { seconds: number } | undefined; + "jump-forward": { seconds: number } | undefined; + "jump-backward": { seconds: number } | undefined; + "remove-media-asset": { projectId: string; assetId: string }; + "remove-media-assets": { projectId: string; assetIds: string[] }; +}; + +type TKeysWithValueUndefined<T> = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +export type TActionWithArgs = keyof TActionArgsMap; + +export type TActionWithOptionalArgs = + | TActionWithNoArgs + | TKeysWithValueUndefined<TActionArgsMap>; + +export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>; + +export type TArgOfAction<A extends TAction> = A extends TActionWithArgs + ? TActionArgsMap[A] + : undefined; + +export type TActionFunc<A extends TAction> = A extends TActionWithArgs + ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void + : (_?: undefined, trigger?: TInvocationTrigger) => void; + +export type TInvocationTrigger = "keypress" | "mouseclick"; + +export type TBoundActionList = { + [A in TAction]?: Array<TActionFunc<A>>; +}; + +export type TActionHandlerOptions = + | MutableRefObject<boolean> + | boolean + | undefined; diff --git a/apps/web/src/actions/use-action-handler.ts b/apps/web/src/actions/use-action-handler.ts index a8bb0054..d4a37b50 100644 --- a/apps/web/src/actions/use-action-handler.ts +++ b/apps/web/src/actions/use-action-handler.ts @@ -8,6 +8,7 @@ import type { } from "@/actions"; import { bindAction, unbindAction } from "@/actions"; +// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive). export function useActionHandler<A extends TAction>( action: A, handler: TActionFunc<A>, diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index e62a8ce0..e5d3a23b 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -1,11 +1,20 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { useTimelineStore } from "@/timeline/timeline-store"; import { useActionHandler } from "@/actions/use-action-handler"; import { useEditor } from "@/editor/use-editor"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + mediaTimeFromSeconds, + minMediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; import { getElementsAtTime, hasMediaId } from "@/timeline"; import { cancelInteraction } from "@/editor/cancel-interaction"; @@ -16,6 +25,7 @@ import { clearActiveScope, type ScopeEntry, } from "@/selection/scope"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; export function useEditorActions() { const editor = useEditor(); @@ -27,47 +37,34 @@ export function useEditorActions() { const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); - const hasTimelineSelectionRef = useRef(false); - const clearTimelineSelectionRef = useRef(() => {}); - const clearTimelineActiveSelectionRef = useRef(() => {}); - const timelineScopeRef = useRef<ScopeEntry | null>(null); const hasTimelineSelection = selectedElements.length > 0 || selectedKeyframes.length > 0 || selectedMaskPointSelection !== null; - - hasTimelineSelectionRef.current = hasTimelineSelection; - clearTimelineSelectionRef.current = () => { + const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection); + const clearTimelineSelectionRef = useCommittedRef(() => { editor.selection.clearSelection(); - }; - clearTimelineActiveSelectionRef.current = () => { + }); + const clearTimelineActiveSelectionRef = useCommittedRef(() => { editor.selection.clearMostSpecificSelection(); - }; - - if (!timelineScopeRef.current) { - timelineScopeRef.current = { - hasSelection: () => hasTimelineSelectionRef.current, - clear: () => { - clearTimelineSelectionRef.current(); - }, - clearActive: () => { - clearTimelineActiveSelectionRef.current(); - }, - }; - } + }); + const [timelineScope] = useState<ScopeEntry>(() => ({ + hasSelection: () => hasTimelineSelectionRef.current, + clear: () => { + clearTimelineSelectionRef.current(); + }, + clearActive: () => { + clearTimelineActiveSelectionRef.current(); + }, + })); useEffect(() => { if (!hasTimelineSelection) { return; } - const timelineScope = timelineScopeRef.current; - if (!timelineScope) { - return; - } - return activateScope({ entry: timelineScope }); - }, [hasTimelineSelection]); + }, [hasTimelineSelection, timelineScope]); useActionHandler( "toggle-play", @@ -83,7 +80,7 @@ export function useEditorActions() { if (editor.playback.getIsPlaying()) { editor.playback.toggle(); } - editor.playback.seek({ time: 0 }); + editor.playback.seek({ time: ZERO_MEDIA_TIME }); }, undefined, ); @@ -92,11 +89,15 @@ export function useEditorActions() { "seek-forward", (args) => { const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -106,8 +107,15 @@ export function useEditorActions() { "seek-backward", (args) => { const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -117,15 +125,20 @@ export function useEditorActions() { "frame-step-forward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); - editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + ticksPerFrame, + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, ), }); + editor.playback.seek({ + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), + }); }, undefined, ); @@ -134,11 +147,19 @@ export function useEditorActions() { "frame-step-backward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ), + }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), }); }, undefined, @@ -148,11 +169,15 @@ export function useEditorActions() { "jump-forward", (args) => { const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -162,8 +187,15 @@ export function useEditorActions() { "jump-backward", (args) => { const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -172,7 +204,7 @@ export function useEditorActions() { useActionHandler( "goto-start", () => { - editor.playback.seek({ time: 0 }); + editor.playback.seek({ time: ZERO_MEDIA_TIME }); }, undefined, ); @@ -273,7 +305,7 @@ export function useEditorActions() { if (!selectedMaskPointSelection) { return; } - editor.timeline.deleteCustomMaskPoints({ + editor.timeline.deleteFreeformPathMaskPoints({ trackId: selectedMaskPointSelection.trackId, elementId: selectedMaskPointSelection.elementId, maskId: selectedMaskPointSelection.maskId, diff --git a/apps/web/src/actions/use-keybindings.ts b/apps/web/src/actions/use-keybindings.ts index c129795c..e9d78982 100644 --- a/apps/web/src/actions/use-keybindings.ts +++ b/apps/web/src/actions/use-keybindings.ts @@ -33,7 +33,7 @@ export function useKeybindingsListener() { const isTextInput = activeElement instanceof HTMLElement && isTypableDOMElement({ element: activeElement }); - const boundAction = binding ? keybindings[binding] : undefined; + const boundAction = binding ? keybindings.get(binding) : undefined; if (normalizedKey === "escape" && isTextInput) { activeElement.blur(); diff --git a/apps/web/src/actions/use-keyboard-shortcuts-help.ts b/apps/web/src/actions/use-keyboard-shortcuts-help.ts index 7681e664..89a17887 100644 --- a/apps/web/src/actions/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/actions/use-keyboard-shortcuts-help.ts @@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() { const { keybindings } = useKeybindingsStore(); const shortcuts = useMemo(() => { - const result: KeyboardShortcut[] = []; - const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {}; + const actionToKeys = new Map<TActionWithOptionalArgs, string[]>(); - for (const [key, action] of Object.entries(keybindings) as Array< - [string, TActionWithOptionalArgs | undefined] - >) { - if (action) { - if (!actionToKeys[action]) { - actionToKeys[action] = []; - } - actionToKeys[action].push(formatKey({ key })); + for (const [key, action] of keybindings) { + const existing = actionToKeys.get(action); + if (existing) { + existing.push(formatKey({ key })); + } else { + actionToKeys.set(action, [formatKey({ key })]); } } - for (const [action, keys] of Object.entries(actionToKeys) as Array< - [TActionWithOptionalArgs, string[]] - >) { + const result: KeyboardShortcut[] = []; + for (const [action, keys] of actionToKeys) { const actionDef = ACTIONS[action]; - if (!actionDef) { - continue; - } + if (!actionDef) continue; result.push({ id: action, keys, diff --git a/apps/web/src/animation/__tests__/animated-params.test.ts b/apps/web/src/animation/__tests__/animated-params.test.ts index 7b9d312d..a47b0382 100644 --- a/apps/web/src/animation/__tests__/animated-params.test.ts +++ b/apps/web/src/animation/__tests__/animated-params.test.ts @@ -1,15 +1,15 @@ import { describe, expect, test } from "bun:test"; import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamNumericRange, - getAnimationParamValueKind, -} from "@/animation/animated-params"; + coerceParamValue, + getParamDefaultInterpolation, + getParamNumericRange, + getParamValueKind, +} from "@/params"; describe("animated params", () => { test("snaps and clamps number params", () => { expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "intensity", label: "Intensity", @@ -24,7 +24,7 @@ describe("animated params", () => { ).toBe(0.5); expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "intensity", label: "Intensity", @@ -49,14 +49,14 @@ describe("animated params", () => { max: 1, step: 0.25, }; - expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: true })).toBeNull(); + expect(coerceParamValue({ param, value: Number.NaN })).toBeNull(); + expect(coerceParamValue({ param, value: "0.5" })).toBeNull(); + expect(coerceParamValue({ param, value: true })).toBeNull(); }); test("passthrough with step <= 0 guard", () => { expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "x", label: "X", @@ -81,13 +81,13 @@ describe("animated params", () => { { value: "multiply", label: "Multiply" }, ], }; - expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal"); - expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply"); + expect(coerceParamValue({ param, value: "normal" })).toBe("normal"); + expect(coerceParamValue({ param, value: "multiply" })).toBe("multiply"); }); test("rejects select values outside the allowed options", () => { expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "blend", label: "Blend", @@ -111,9 +111,9 @@ describe("animated params", () => { default: "normal", options: [{ value: "normal", label: "Normal" }], }; - expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull(); + expect(coerceParamValue({ param, value: 42 })).toBeNull(); + expect(coerceParamValue({ param, value: null })).toBeNull(); + expect(coerceParamValue({ param, value: undefined })).toBeNull(); }); test("boolean params accept booleans and reject other types", () => { @@ -123,10 +123,10 @@ describe("animated params", () => { type: "boolean" as const, default: true, }; - expect(coerceAnimationParamValue({ param, value: true })).toBe(true); - expect(coerceAnimationParamValue({ param, value: false })).toBe(false); - expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull(); + expect(coerceParamValue({ param, value: true })).toBe(true); + expect(coerceParamValue({ param, value: false })).toBe(false); + expect(coerceParamValue({ param, value: 1 })).toBeNull(); + expect(coerceParamValue({ param, value: "true" })).toBeNull(); }); test("color params accept strings and reject other types", () => { @@ -136,14 +136,14 @@ describe("animated params", () => { type: "color" as const, default: "#ffffff", }; - expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000"); - expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); + expect(coerceParamValue({ param, value: "#ff0000" })).toBe("#ff0000"); + expect(coerceParamValue({ param, value: 0xff0000 })).toBeNull(); + expect(coerceParamValue({ param, value: null })).toBeNull(); }); test("getAnimationParamValueKind maps param type to binding kind", () => { expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "n", label: "N", @@ -155,17 +155,17 @@ describe("animated params", () => { }), ).toBe("number"); expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "c", label: "C", type: "color", default: "#fff" }, }), ).toBe("color"); expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "b", label: "B", type: "boolean", default: false }, }), ).toBe("discrete"); expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "s", label: "S", @@ -179,7 +179,7 @@ describe("animated params", () => { test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => { expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "n", label: "N", @@ -191,17 +191,17 @@ describe("animated params", () => { }), ).toBe("linear"); expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "c", label: "C", type: "color", default: "#fff" }, }), ).toBe("linear"); expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "b", label: "B", type: "boolean", default: false }, }), ).toBe("hold"); expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "s", label: "S", @@ -215,7 +215,7 @@ describe("animated params", () => { test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => { expect( - getAnimationParamNumericRange({ + getParamNumericRange({ param: { key: "intensity", label: "Intensity", @@ -228,12 +228,12 @@ describe("animated params", () => { }), ).toEqual({ min: 0, max: 1, step: 0.1 }); expect( - getAnimationParamNumericRange({ + getParamNumericRange({ param: { key: "c", label: "C", type: "color", default: "#fff" }, }), ).toBeUndefined(); expect( - getAnimationParamNumericRange({ + getParamNumericRange({ param: { key: "b", label: "B", type: "boolean", default: false }, }), ).toBeUndefined(); diff --git a/apps/web/src/animation/__tests__/binding-values.test.ts b/apps/web/src/animation/__tests__/binding-values.test.ts deleted file mode 100644 index a1f2010c..00000000 --- a/apps/web/src/animation/__tests__/binding-values.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - composeAnimationValue, - createAnimationBinding, -} from "@/animation/binding-values"; - -describe("binding values", () => { - test("formats composed animated colors as hex", () => { - const binding = createAnimationBinding({ - path: "color", - kind: "color", - }); - - expect( - composeAnimationValue({ - binding, - componentValues: { - r: 1, - g: 0, - b: 0, - a: 1, - }, - }), - ).toBe("#ff0000"); - }); -}); diff --git a/apps/web/src/animation/animated-params.ts b/apps/web/src/animation/animated-params.ts deleted file mode 100644 index bc27b15d..00000000 --- a/apps/web/src/animation/animated-params.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { snapToStep } from "@/utils/math"; -import type { ParamDefinition } from "@/params"; -import type { DynamicAnimationPathValue, NumericSpec } from "./types"; - -export function getAnimationParamValueKind({ - param, -}: { - param: ParamDefinition; -}): "number" | "color" | "discrete" { - if (param.type === "number") { - return "number"; - } - - if (param.type === "color") { - return "color"; - } - - return "discrete"; -} - -export function getAnimationParamDefaultInterpolation({ - param, -}: { - param: ParamDefinition; -}): "linear" | "hold" { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; -} - -export function getAnimationParamNumericRange({ - param, -}: { - param: ParamDefinition; -}): NumericSpec | undefined { - if (param.type !== "number") { - return undefined; - } - - return { - min: param.min, - max: param.max, - step: param.step, - }; -} - -/** - * `coerceAnimationParamValue` accepts `unknown` rather than a narrow - * `DynamicAnimationPathValue` because it doubles as a runtime gate for - * untrusted inputs (persisted state, paste payloads, programmatic updates). - * The caller does not need to pre-validate; null means "this value cannot live - * on this param". - */ -export function coerceAnimationParamValue({ - param, - value, -}: { - param: ParamDefinition; - value: unknown; -}): DynamicAnimationPathValue | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const minValue = param.min; - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); - } - - if (param.type === "color") { - return typeof value === "string" ? value : null; - } - - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (typeof value !== "string") { - return null; - } - - return param.options.some((option) => option.value === value) ? value : null; -} diff --git a/apps/web/src/animation/binding-values.ts b/apps/web/src/animation/binding-values.ts deleted file mode 100644 index a46aa9a6..00000000 --- a/apps/web/src/animation/binding-values.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { converter, formatHex, formatHex8, parse } from "culori"; -import type { - AnimationBindingComponent, - AnimationBindingOfKind, - AnimationBindingInstance, - AnimationBindingKind, - ColorAnimationBinding, - DiscreteAnimationBinding, - NumberAnimationBinding, - AnimationPath, - AnimationValue, - DiscreteValue, - Vector2AnimationBinding, - VectorValue, -} from "@/animation/types"; -import { clamp } from "@/utils/math"; - -interface LinearRgba { - r: number; - g: number; - b: number; - a: number; -} - -export type AnimationComponentValue = number | DiscreteValue; - -const toRgb = converter("rgb"); - -function srgbToLinear({ value }: { value: number }): number { - return value <= 0.04045 - ? value / 12.92 - : Math.pow((value + 0.055) / 1.055, 2.4); -} - -function linearToSrgb({ value }: { value: number }): number { - const clamped = clamp({ value, min: 0, max: 1 }); - return clamped <= 0.0031308 - ? clamped * 12.92 - : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; -} - -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null; -} - -export function isVectorValue(value: unknown): value is VectorValue { - return isRecord(value) && typeof value.x === "number" && typeof value.y === "number"; -} - -export type EasingMode = "independent" | "shared"; - -/** - * Declares how easing curves apply to a binding's components. - * "shared" means all components always use the same curve (e.g. color — you never - * want to ease R independently from G/B/A). "independent" means each component - * can have its own curve. - */ -export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode { - return kind === "color" ? "shared" : "independent"; -} - -export function getBindingComponentKeys({ - kind, -}: { - kind: AnimationBindingKind; -}): string[] { - if (kind === "vector2") { - return ["x", "y"]; - } - - if (kind === "color") { - return ["r", "g", "b", "a"]; - } - - return ["value"]; -} - -export function buildBindingChannelId({ - path, - componentKey, -}: { - path: AnimationPath; - componentKey: string; -}): string { - return `${path}:${componentKey}`; -} - -function createBindingComponent<TKey extends string>({ - path, - key, -}: { - path: AnimationPath; - key: TKey; -}): AnimationBindingComponent<TKey> { - return { - key, - channelId: buildBindingChannelId({ path, componentKey: key }), - }; -} - -function cloneBindingComponents<TKey extends string>({ - components, -}: { - components: AnimationBindingComponent<TKey>[]; -}): AnimationBindingComponent<TKey>[] { - return components.map((component) => ({ ...component })); -} - -const animationBindingFactories = { - color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({ - path, - kind: "color", - colorSpace: "srgb-linear", - components: [ - createBindingComponent({ path, key: "r" }), - createBindingComponent({ path, key: "g" }), - createBindingComponent({ path, key: "b" }), - createBindingComponent({ path, key: "a" }), - ], - }), - vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({ - path, - kind: "vector2", - components: [ - createBindingComponent({ path, key: "x" }), - createBindingComponent({ path, key: "y" }), - ], - }), - number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({ - path, - kind: "number", - components: [createBindingComponent({ path, key: "value" })], - }), - discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({ - path, - kind: "discrete", - components: [createBindingComponent({ path, key: "value" })], - }), -} satisfies { - [K in AnimationBindingKind]: ({ - path, - }: { - path: AnimationPath; - }) => AnimationBindingOfKind<K>; -}; - -export function createAnimationBinding<TKind extends AnimationBindingKind>({ - path, - kind, -}: { - path: AnimationPath; - kind: TKind; -}): AnimationBindingOfKind<TKind>; -export function createAnimationBinding({ - path, - kind, -}: { - path: AnimationPath; - kind: AnimationBindingKind; -}): AnimationBindingInstance { - return animationBindingFactories[kind]({ path }); -} - -const animationBindingCloners = { - color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - vector2: ({ - binding, - }: { - binding: Vector2AnimationBinding; - }): Vector2AnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - number: ({ - binding, - }: { - binding: NumberAnimationBinding; - }): NumberAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - discrete: ({ - binding, - }: { - binding: DiscreteAnimationBinding; - }): DiscreteAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), -} satisfies { - [K in AnimationBindingKind]: ({ - binding, - }: { - binding: AnimationBindingOfKind<K>; - }) => AnimationBindingOfKind<K>; -}; - -export function cloneAnimationBinding<TKind extends AnimationBindingKind>({ - binding, -}: { - binding: AnimationBindingOfKind<TKind>; -}): AnimationBindingOfKind<TKind>; -export function cloneAnimationBinding({ - binding, -}: { - binding: AnimationBindingInstance; -}): AnimationBindingInstance { - switch (binding.kind) { - case "color": - return animationBindingCloners.color({ binding }); - case "vector2": - return animationBindingCloners.vector2({ binding }); - case "number": - return animationBindingCloners.number({ binding }); - case "discrete": - return animationBindingCloners.discrete({ binding }); - } -} - -export function parseColorToLinearRgba({ - color, -}: { - color: string; -}): LinearRgba | null { - const parsed = parse(color); - const rgb = parsed ? toRgb(parsed) : null; - if (!rgb) { - return null; - } - - return { - r: srgbToLinear({ value: rgb.r ?? 0 }), - g: srgbToLinear({ value: rgb.g ?? 0 }), - b: srgbToLinear({ value: rgb.b ?? 0 }), - a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }), - }; -} - -export function formatLinearRgba({ - color, -}: { - color: LinearRgba; -}): string { - const rgb = { - mode: "rgb", - r: linearToSrgb({ value: color.r }), - g: linearToSrgb({ value: color.g }), - b: linearToSrgb({ value: color.b }), - alpha: clamp({ value: color.a, min: 0, max: 1 }), - } as const; - return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); -} - -export function decomposeAnimationValue({ - kind, - value, -}: { - kind: AnimationBindingKind; - value: AnimationValue; -}): Record<string, AnimationComponentValue> | null { - if (kind === "number") { - return typeof value === "number" ? { value } : null; - } - - if (kind === "vector2") { - return isVectorValue(value) ? { x: value.x, y: value.y } : null; - } - - if (kind === "color") { - if (typeof value !== "string") { - return null; - } - const parsed = parseColorToLinearRgba({ color: value }); - if (!parsed) { - return null; - } - return { - r: parsed.r, - g: parsed.g, - b: parsed.b, - a: parsed.a, - }; - } - - return typeof value === "string" || typeof value === "boolean" - ? { value } - : null; -} - -export function composeAnimationValue({ - binding, - componentValues, -}: { - binding: AnimationBindingInstance; - componentValues: Record<string, AnimationComponentValue | undefined>; -}): AnimationValue | null { - if (binding.kind === "number") { - const value = componentValues.value; - return typeof value === "number" ? value : null; - } - - if (binding.kind === "vector2") { - const x = componentValues.x; - const y = componentValues.y; - return typeof x === "number" && typeof y === "number" ? { x, y } : null; - } - - if (binding.kind === "color") { - const r = componentValues.r; - const g = componentValues.g; - const b = componentValues.b; - const a = componentValues.a; - if ( - typeof r !== "number" || - typeof g !== "number" || - typeof b !== "number" || - typeof a !== "number" - ) { - return null; - } - return formatLinearRgba({ - color: { - r, - g, - b, - a, - }, - }); - } - - const value = componentValues.value; - return typeof value === "string" || typeof value === "boolean" ? value : null; -} diff --git a/apps/web/src/animation/channel-data.ts b/apps/web/src/animation/channel-data.ts new file mode 100644 index 00000000..2fc08b5c --- /dev/null +++ b/apps/web/src/animation/channel-data.ts @@ -0,0 +1,57 @@ +import type { + AnimationChannel, + ChannelData, + CompositeChannelData, +} from "@/animation/types"; + +const LEGACY_ANIMATION_STORAGE_KEYS = new Set(["bindings", "channels"]); + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + +export function isLeafChannelData( + data: ChannelData | undefined, +): data is AnimationChannel { + return isRecord(data) && Array.isArray(data.keys); +} + +export function isCompositeChannelData( + data: ChannelData | undefined, +): data is CompositeChannelData { + return isRecord(data) && !Array.isArray(data.keys); +} + +export function getChannelsFromData({ + data, +}: { + data: ChannelData | undefined; +}): AnimationChannel[] { + if (isLeafChannelData(data)) { + return [data]; + } + if (!isCompositeChannelData(data)) { + return []; + } + return Object.values(data).filter(isLeafChannelData); +} + +export function getChannelEntriesFromData({ + data, +}: { + data: ChannelData | undefined; +}): Array<[string, AnimationChannel]> { + if (isLeafChannelData(data)) { + return [["value", data]]; + } + if (!isCompositeChannelData(data)) { + return []; + } + return Object.entries(data).flatMap(([componentKey, channel]) => + isLeafChannelData(channel) ? [[componentKey, channel]] : [], + ); +} + +export function isAnimationStorageKey({ key }: { key: string }): boolean { + return !LEGACY_ANIMATION_STORAGE_KEYS.has(key); +} diff --git a/apps/web/src/animation/curve-bridge.ts b/apps/web/src/animation/curve-bridge.ts index af411dd1..c947e7dc 100644 --- a/apps/web/src/animation/curve-bridge.ts +++ b/apps/web/src/animation/curve-bridge.ts @@ -7,6 +7,7 @@ import type { NormalizedCubicBezier, ScalarAnimationKey, } from "@/animation/types"; +import { roundMediaTime } from "@/wasm"; const VALUE_EPSILON = 1e-6; @@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({ return { rightHandle: { - dt: spanTime * x1, + dt: roundMediaTime({ time: spanTime * x1 }), dv: effectiveSpanValue * y1, }, leftHandle: { - dt: spanTime * (x2 - 1), + dt: roundMediaTime({ time: spanTime * (x2 - 1) }), dv: effectiveSpanValue * (y2 - 1), }, }; diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts index e1c442f7..3def2109 100644 --- a/apps/web/src/animation/effect-param-channel.ts +++ b/apps/web/src/animation/effect-param-channel.ts @@ -70,7 +70,7 @@ export function resolveEffectParamsAtTime({ for (const [paramKey, staticValue] of Object.entries(params)) { const path = buildEffectParamPath({ effectId, paramKey }); - resolved[paramKey] = animations?.bindings[path] + resolved[paramKey] = animations?.[path] ? resolveAnimationPathValueAtTime({ animations, propertyPath: path, diff --git a/apps/web/src/animation/graph-channels.ts b/apps/web/src/animation/graph-channels.ts index d6482602..cef1397a 100644 --- a/apps/web/src/animation/graph-channels.ts +++ b/apps/web/src/animation/graph-channels.ts @@ -1,21 +1,35 @@ import type { - AnimationBindingInstance, AnimationPath, ElementAnimations, + ChannelData, ScalarAnimationChannel, ScalarGraphChannel, ScalarGraphKeyframeContext, } from "@/animation/types"; +import type { ChannelEasingMode } from "@/params"; +import { isCompositeChannelData, isLeafChannelData } from "./channel-data"; +import { isScalarChannel } from "./interpolation"; export interface EditableScalarChannels { - binding: AnimationBindingInstance; + easingMode: ChannelEasingMode; channels: ScalarGraphChannel[]; } function isScalarAnimationChannel( - channel: ElementAnimations["channels"][string], + channel: ChannelData | undefined, ): channel is ScalarAnimationChannel { - return channel?.kind === "scalar"; + return isLeafChannelData(channel) && isScalarChannel(channel); +} + +function getEasingModeForChannelData({ + data, +}: { + data: ChannelData | undefined; +}): ChannelEasingMode { + return isCompositeChannelData(data) && + ["r", "g", "b", "a"].every((componentKey) => componentKey in data) + ? "shared" + : "independent"; } export function getEditableScalarChannels({ @@ -25,13 +39,15 @@ export function getEditableScalarChannels({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; }): EditableScalarChannels | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + const data = animations?.[propertyPath]; + if (!data) { return null; } - const channels = binding.components.flatMap((component) => { - const channel = animations?.channels[component.channelId]; + const channelEntries = isLeafChannelData(data) + ? [["value", data] as const] + : Object.entries(data); + const channels = channelEntries.flatMap(([componentKey, channel]) => { if (!isScalarAnimationChannel(channel)) { return []; } @@ -39,14 +55,13 @@ export function getEditableScalarChannels({ return [ { propertyPath, - componentKey: component.key, - channelId: component.channelId, + componentKey, channel, } satisfies ScalarGraphChannel, ]; }); - return { binding, channels }; + return { easingMode: getEasingModeForChannelData({ data }), channels }; } export function getEditableScalarChannel({ diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts index d0bfdded..32f84714 100644 --- a/apps/web/src/animation/graphic-param-channel.ts +++ b/apps/web/src/animation/graphic-param-channel.ts @@ -49,7 +49,7 @@ export function resolveGraphicParamsAtTime({ for (const param of definitions) { const path = buildGraphicParamPath({ paramKey: param.key }); - if (!animations?.bindings[path]) { + if (!animations?.[path]) { continue; } diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts index af3b910d..f3d8aca4 100644 --- a/apps/web/src/animation/index.ts +++ b/apps/web/src/animation/index.ts @@ -65,10 +65,8 @@ export { } from "./property-groups"; export { - type EasingMode, - getEasingModeForKind, -} from "./binding-values"; - -export { isAnimationPath, + isAnimationPropertyPath, } from "./path"; + +export type { NumericSpec } from "./types"; diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts index 6bab66c4..cdb8bcc9 100644 --- a/apps/web/src/animation/interpolation.ts +++ b/apps/web/src/animation/interpolation.ts @@ -1,13 +1,15 @@ import type { AnimationChannel, AnimationInterpolation, - AnimationValue, + Channel, DiscreteAnimationChannel, DiscreteValue, ScalarAnimationChannel, ScalarAnimationKey, ScalarSegmentType, } from "@/animation/types"; +import type { ParamValue } from "@/params"; +import { mediaTime } from "@/wasm"; import { getBezierPoint, getDefaultLeftHandle, @@ -63,9 +65,13 @@ function normalizeRightHandle({ return undefined; } - const span = Math.max(1, rightKey.time - leftKey.time); + const span = mediaTime({ + ticks: Math.max(1, rightKey.time - leftKey.time), + }); return { - dt: Math.min(span, Math.max(0, handle.dt)), + dt: mediaTime({ + ticks: Math.min(span, Math.max(0, handle.dt)), + }), dv: handle.dv, }; } @@ -83,9 +89,13 @@ function normalizeLeftHandle({ return undefined; } - const span = Math.max(1, rightKey.time - leftKey.time); + const span = mediaTime({ + ticks: Math.max(1, rightKey.time - leftKey.time), + }); return { - dt: Math.max(-span, Math.min(0, handle.dt)), + dt: mediaTime({ + ticks: Math.max(-span, Math.min(0, handle.dt)), + }), dv: handle.dv, }; } @@ -102,7 +112,7 @@ function normalizeScalarKey({ }; } -function normalizeScalarChannel({ +export function normalizeScalarChannel({ channel, }: { channel: ScalarAnimationChannel; @@ -145,17 +155,11 @@ function normalizeScalarChannel({ }; } -export function normalizeChannel<TChannel extends AnimationChannel>({ +export function normalizeDiscreteChannel({ channel, }: { - channel: TChannel; -}): TChannel { - if (channel.kind === "scalar") { - return normalizeScalarChannel({ - channel, - }) as TChannel; - } - + channel: DiscreteAnimationChannel; +}): DiscreteAnimationChannel { return { ...channel, keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => @@ -164,7 +168,39 @@ export function normalizeChannel<TChannel extends AnimationChannel>({ rightTime: rightKeyframe.time, }), ), - } as TChannel; + }; +} + +export function isScalarChannel(channel: AnimationChannel): channel is ScalarAnimationChannel { + return ( + "extrapolation" in channel || + channel.keys.some((keyframe) => "segmentToNext" in keyframe) + ); +} + +export function normalizeChannel({ + channel, +}: { + channel: ScalarAnimationChannel; +}): ScalarAnimationChannel; +export function normalizeChannel({ + channel, +}: { + channel: DiscreteAnimationChannel; +}): DiscreteAnimationChannel; +export function normalizeChannel({ + channel, +}: { + channel: AnimationChannel; +}): AnimationChannel; +export function normalizeChannel({ + channel, +}: { + channel: AnimationChannel; +}): AnimationChannel { + return isScalarChannel(channel) + ? normalizeScalarChannel({ channel }) + : normalizeDiscreteChannel({ channel }); } function extrapolateScalarEdge({ @@ -207,7 +243,7 @@ export function getScalarChannelValueAtTime({ time, fallbackValue, }: { - channel: ScalarAnimationChannel | undefined; + channel: Channel<number> | undefined; time: number; fallbackValue: number; }): number { @@ -215,9 +251,7 @@ export function getScalarChannelValueAtTime({ return fallbackValue; } - const normalizedChannel = normalizeChannel({ - channel, - }); + const normalizedChannel = normalizeScalarChannel({ channel }); const firstKey = normalizedChannel.keys[0]; const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; if (!firstKey || !lastKey) { @@ -319,7 +353,7 @@ export function getDiscreteChannelValueAtTime({ time, fallbackValue, }: { - channel: DiscreteAnimationChannel | undefined; + channel: Channel<DiscreteValue> | undefined; time: number; fallbackValue: DiscreteValue; }): DiscreteValue { @@ -327,9 +361,7 @@ export function getDiscreteChannelValueAtTime({ return fallbackValue; } - const normalizedChannel = normalizeChannel({ - channel, - }); + const normalizedChannel = normalizeDiscreteChannel({ channel }); let currentValue = fallbackValue; for (const key of normalizedChannel.keys) { if (time < key.time) { @@ -345,16 +377,34 @@ export function getChannelValueAtTime({ time, fallbackValue, }: { + channel: Channel<number> | undefined; + time: number; + fallbackValue: number; +}): number; +export function getChannelValueAtTime<TValue extends DiscreteValue>({ + channel, + time, + fallbackValue, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + fallbackValue: TValue; +}): TValue; +export function getChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { channel: AnimationChannel | undefined; time: number; - fallbackValue: AnimationValue; -}): AnimationValue { + fallbackValue: ParamValue; +}): ParamValue { if (!channel || channel.keys.length === 0) { return fallbackValue; } - if (channel.kind === "scalar") { - return typeof fallbackValue === "number" + if (typeof fallbackValue === "number") { + return isScalarChannel(channel) ? getScalarChannelValueAtTime({ channel, time, @@ -368,7 +418,7 @@ export function getChannelValueAtTime({ } return getDiscreteChannelValueAtTime({ - channel, + channel: isScalarChannel(channel) ? undefined : channel, time, fallbackValue, }); diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts index e65db881..95f7fcf0 100644 --- a/apps/web/src/animation/keyframe-query.ts +++ b/apps/web/src/animation/keyframe-query.ts @@ -1,75 +1,75 @@ import type { - AnimationBindingInstance, AnimationChannel, AnimationPath, + ChannelData, ElementAnimations, ElementKeyframe, } from "@/animation/types"; +import { formatLinearRgba } from "@/params"; import { - type AnimationComponentValue, - composeAnimationValue, -} from "./binding-values"; + getChannelEntriesFromData, + isAnimationStorageKey, +} from "./channel-data"; import { getChannelValueAtTime, getScalarSegmentInterpolation, + isScalarChannel, } from "./interpolation"; import { isAnimationPath } from "./path"; -function getBindingFallbackValue({ +function getChannelFallbackValue({ channel, }: { - channel: ElementAnimations["channels"][string]; + channel: AnimationChannel; }) { - if (!channel || channel.keys.length === 0) { - return channel?.kind === "discrete" ? false : 0; + if (channel.keys.length === 0) { + return isScalarChannel(channel) ? 0 : false; } return channel.keys[0].value; } -interface BindingKeyframeMatch { +interface ChannelKeyframeMatch { + componentKey: string; componentIndex: number; channel: AnimationChannel; keyframe: AnimationChannel["keys"][number]; } -function getBindingKeyframeMatches({ - animations, - binding, +function getChannelKeyframeMatches({ + data, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; -}): BindingKeyframeMatch[] { - return binding.components.flatMap((component, componentIndex) => { - const channel = animations.channels[component.channelId]; - if (!channel || channel.keys.length === 0) { - return []; - } + data: ChannelData | undefined; +}): ChannelKeyframeMatch[] { + return getChannelEntriesFromData({ data }).flatMap( + ([componentKey, channel], componentIndex) => { + if (channel.keys.length === 0) { + return []; + } - return channel.keys.map((keyframe) => ({ - componentIndex, - channel, - keyframe, - })); - }); + return channel.keys.map((keyframe) => ({ + componentKey, + componentIndex, + channel, + keyframe, + })); + }, + ); } -function getUniqueBindingKeyframeMatches({ - animations, - binding, +function getUniqueChannelKeyframeMatches({ + data, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; -}): BindingKeyframeMatch[] { - const sortedMatches = getBindingKeyframeMatches({ - animations, - binding, + data: ChannelData | undefined; +}): ChannelKeyframeMatch[] { + const sortedMatches = getChannelKeyframeMatches({ + data, }).sort( (leftMatch, rightMatch) => leftMatch.keyframe.time - rightMatch.keyframe.time || leftMatch.componentIndex - rightMatch.componentIndex, ); - const uniqueMatches: BindingKeyframeMatch[] = []; + const uniqueMatches: ChannelKeyframeMatch[] = []; for (const match of sortedMatches) { const previousMatch = uniqueMatches[uniqueMatches.length - 1]; @@ -92,11 +92,11 @@ function getUniqueBindingKeyframeMatches({ return uniqueMatches; } -function getPreferredBindingKeyframeMatch({ +function getPreferredChannelKeyframeMatch({ matches, }: { - matches: BindingKeyframeMatch[]; -}): BindingKeyframeMatch | null { + matches: ChannelKeyframeMatch[]; +}): ChannelKeyframeMatch | null { return ( matches.find((match) => match.componentIndex === 0) ?? matches[0] ?? @@ -104,35 +104,67 @@ function getPreferredBindingKeyframeMatch({ ); } -function getComposedBindingValueAtTime({ - animations, - binding, +function getChannelValue({ + channel, time, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; + channel: AnimationChannel; time: number; }) { - const componentValues = Object.fromEntries( - binding.components.map((component) => { - const channel = animations.channels[component.channelId]; - return [ - component.key, - getChannelValueAtTime({ - channel, - time, - fallbackValue: getBindingFallbackValue({ channel }), - }), - ]; - }), - ) as Record<string, AnimationComponentValue | undefined>; - - return composeAnimationValue({ - binding, - componentValues, + const fallbackValue = getChannelFallbackValue({ channel }); + if (typeof fallbackValue === "number") { + return getChannelValueAtTime({ + channel: isScalarChannel(channel) ? channel : undefined, + time, + fallbackValue, + }); + } + return getChannelValueAtTime({ + channel: !isScalarChannel(channel) ? channel : undefined, + time, + fallbackValue, }); } +function getComposedChannelDataValueAtTime({ + data, + time, +}: { + data: ChannelData | undefined; + time: number; +}) { + const entries = getChannelEntriesFromData({ data }); + if (entries.length === 0) { + return null; + } + if (entries.length === 1 && entries[0]?.[0] === "value") { + return getChannelValue({ channel: entries[0][1], time }); + } + + const componentValues = Object.fromEntries( + entries.map(([componentKey, channel]) => [ + componentKey, + getChannelValue({ channel, time }), + ]), + ); + if ( + typeof componentValues.r === "number" && + typeof componentValues.g === "number" && + typeof componentValues.b === "number" && + typeof componentValues.a === "number" + ) { + return formatLinearRgba({ + color: { + r: componentValues.r, + g: componentValues.g, + b: componentValues.b, + a: componentValues.a, + }, + }); + } + return null; +} + function getKeyframeInterpolation({ channel, keyframe, @@ -140,25 +172,22 @@ function getKeyframeInterpolation({ channel: AnimationChannel; keyframe: AnimationChannel["keys"][number]; }) { - return channel.kind === "scalar" && "segmentToNext" in keyframe + return isScalarChannel(channel) && "segmentToNext" in keyframe ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) : "hold"; } function toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; + data: ChannelData | undefined; propertyPath: AnimationPath; - keyframeMatch: BindingKeyframeMatch; + keyframeMatch: ChannelKeyframeMatch; }): ElementKeyframe | null { - const value = getComposedBindingValueAtTime({ - animations, - binding, + const value = getComposedChannelDataValueAtTime({ + data, time: keyframeMatch.keyframe.time, }); if (value === null) { @@ -186,19 +215,19 @@ export function getElementKeyframes({ return []; } - return Object.entries(animations.bindings).flatMap( - ([propertyPath, binding]) => { - if (!binding || !isAnimationPath(propertyPath)) { + return Object.entries(animations).filter(([key]) => + isAnimationStorageKey({ key }), + ).flatMap( + ([propertyPath, data]) => { + if (!data || !isAnimationPath(propertyPath)) { return []; } - return getUniqueBindingKeyframeMatches({ - animations, - binding, + return getUniqueChannelKeyframeMatches({ + data, }).flatMap((keyframeMatch) => { const keyframe = toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }); @@ -219,13 +248,8 @@ export function hasKeyframesForPath({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; }): boolean { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return false; - } - - return binding.components.some((component) => - Boolean(animations?.channels[component.channelId]?.keys.length), + return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some( + ([, channel]) => channel.keys.length > 0, ); } @@ -238,24 +262,22 @@ export function getKeyframeAtTime({ propertyPath: AnimationPath; time: number; }): ElementKeyframe | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + const data = animations?.[propertyPath]; + if (!data) { return null; } - const keyframeMatch = getPreferredBindingKeyframeMatch({ - matches: getBindingKeyframeMatches({ - animations, - binding, - }).filter(({ keyframe }) => keyframe.time === time), + const keyframeMatch = getPreferredChannelKeyframeMatch({ + matches: getChannelKeyframeMatches({ data }).filter( + ({ keyframe }) => keyframe.time === time, + ), }); if (!keyframeMatch) { return null; } return toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }); @@ -270,24 +292,22 @@ export function getKeyframeById({ propertyPath: AnimationPath; keyframeId: string; }): ElementKeyframe | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + const data = animations?.[propertyPath]; + if (!data) { return null; } - const keyframeMatch = getPreferredBindingKeyframeMatch({ - matches: getBindingKeyframeMatches({ - animations, - binding, - }).filter(({ keyframe }) => keyframe.id === keyframeId), + const keyframeMatch = getPreferredChannelKeyframeMatch({ + matches: getChannelKeyframeMatches({ data }).filter( + ({ keyframe }) => keyframe.id === keyframeId, + ), }); if (!keyframeMatch) { return null; } return toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }); diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts index ff0cef8f..c3fd8202 100644 --- a/apps/web/src/animation/keyframes.ts +++ b/apps/web/src/animation/keyframes.ts @@ -1,10 +1,8 @@ import type { - AnimationBindingInstance, - AnimationBindingKind, AnimationChannel, + ChannelData, AnimationInterpolation, AnimationPath, - AnimationValue, DiscreteAnimationChannel, DiscreteAnimationKey, ElementAnimations, @@ -13,13 +11,18 @@ import type { ScalarCurveKeyframePatch, ScalarSegmentType, } from "@/animation/types"; +import type { + ChannelComponentDefinition, + ParamChannelLayout, + ParamValue, +} from "@/params"; import { - cloneAnimationBinding, - createAnimationBinding, - decomposeAnimationValue, -} from "./binding-values"; + getChannelsFromData, + isCompositeChannelData, + isAnimationStorageKey, + isLeafChannelData, +} from "./channel-data"; import { - getBezierPoint, getDefaultLeftHandle, getDefaultRightHandle, solveBezierProgressForTime, @@ -27,8 +30,17 @@ import { import { getChannelValueAtTime, getScalarSegmentInterpolation, + isScalarChannel, normalizeChannel, + normalizeDiscreteChannel, + normalizeScalarChannel, } from "./interpolation"; +import { + type MediaTime, + roundMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { generateUUID } from "@/utils/id"; function isNearlySameTime({ @@ -49,27 +61,27 @@ function hasChannelKeys({ return Boolean(channel && channel.keys.length > 0); } +function hasChannelData({ data }: { data: ChannelData | undefined }): boolean { + return getChannelsFromData({ data }).some((channel) => + hasChannelKeys({ channel }), + ); +} + function toAnimation({ animations, }: { animations: ElementAnimations; }): ElementAnimations | undefined { - const nextBindings = Object.fromEntries( - Object.entries(animations.bindings).filter(([, binding]) => binding), - ); - const nextChannels = Object.fromEntries( - Object.entries(animations.channels).filter(([, channel]) => - hasChannelKeys({ channel }), + const nextAnimations = Object.fromEntries( + Object.entries(animations).filter( + ([key, data]) => isAnimationStorageKey({ key }) && hasChannelData({ data }), ), ); - if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) { + if (Object.keys(nextAnimations).length === 0) { return undefined; } - return { - bindings: nextBindings, - channels: nextChannels, - }; + return nextAnimations; } function cloneAnimationsState({ @@ -77,34 +89,88 @@ function cloneAnimationsState({ }: { animations: ElementAnimations | undefined; }): ElementAnimations { - return { - bindings: { ...(animations?.bindings ?? {}) }, - channels: { ...(animations?.channels ?? {}) }, - }; + return { ...(animations ?? {}) }; } -function getBindingChannelKind({ - kind, +function getChannelFromData({ + data, + componentKey, }: { - kind: AnimationBindingKind; -}): AnimationChannel["kind"] { - return kind === "discrete" ? "discrete" : "scalar"; + data: ChannelData | undefined; + componentKey: string; +}): AnimationChannel | undefined { + if (isLeafChannelData(data)) { + return componentKey === "value" ? data : undefined; + } + if (isCompositeChannelData(data)) { + return data[componentKey]; + } + return undefined; } -function getPrimaryComponent({ - binding, +type LayoutComponent = ChannelComponentDefinition<string>; + +function getLayoutComponents({ + channelLayout, }: { - binding: AnimationBindingInstance; -}) { - return binding.components[0] ?? null; + channelLayout: ParamChannelLayout; +}): LayoutComponent[] { + return channelLayout.kind === "leaf" + ? [channelLayout.component] + : channelLayout.components; } -function getPrimaryChannelId({ - binding, +function getPrimaryComponentKey({ + channelLayout, }: { - binding: AnimationBindingInstance; -}) { - return getPrimaryComponent({ binding })?.channelId ?? null; + channelLayout: ParamChannelLayout; +}): string { + return getLayoutComponents({ channelLayout })[0]?.key ?? "value"; +} + +function getPrimaryChannelFromData({ + data, + channelLayout, +}: { + data: ChannelData | undefined; + channelLayout: ParamChannelLayout; +}): AnimationChannel | undefined { + return getChannelFromData({ + data, + componentKey: getPrimaryComponentKey({ channelLayout }), + }); +} + +function setChannelInData({ + data, + componentKey, + channel, +}: { + data: ChannelData | undefined; + componentKey: string; + channel: AnimationChannel | undefined; +}): ChannelData | undefined { + if (componentKey === "value") { + return channel; + } + const components = isCompositeChannelData(data) ? { ...data } : {}; + if (channel && hasChannelKeys({ channel })) { + components[componentKey] = channel; + } else { + delete components[componentKey]; + } + return Object.keys(components).length > 0 ? components : undefined; +} + +function getChannelDataEntries({ + data, +}: { + data: ChannelData | undefined; +}): Array<[string, AnimationChannel | undefined]> { + if (isLeafChannelData(data)) { + return [["value", data]]; + } + return isCompositeChannelData(data) ? Object.entries(data) : []; } function getScalarSegmentType({ @@ -118,14 +184,14 @@ function getScalarSegmentType({ return interpolation === "bezier" ? "bezier" : "linear"; } -function getInterpolationForBinding({ - kind, +function getInterpolationForComponent({ + component, interpolation, }: { - kind: AnimationBindingKind; + component: LayoutComponent; interpolation: AnimationInterpolation | undefined; }): AnimationInterpolation { - if (kind === "discrete") { + if (component.valueKind === "discrete") { return "hold"; } @@ -137,25 +203,25 @@ function getInterpolationForBinding({ return interpolation; } - return "linear"; + return component.defaultInterpolation; } -function createEmptyChannelForBindingKind({ - kind, +function decomposeChannelLayoutValue({ + channelLayout, + value, }: { - kind: AnimationBindingKind; -}): AnimationChannel { - if (kind === "discrete") { - return { - kind: "discrete", - keys: [], - } satisfies DiscreteAnimationChannel; + channelLayout: ParamChannelLayout; + value: ParamValue; +}): Record<string, ParamValue> | null { + if (channelLayout.kind === "leaf") { + return { [channelLayout.component.key]: value }; + } + if (typeof value !== "string") { + return null; } - return { - kind: "scalar", - keys: [], - } satisfies ScalarAnimationChannel; + const components = channelLayout.decompose(value); + return components ? { ...components } : null; } function createScalarKey({ @@ -166,7 +232,7 @@ function createScalarKey({ previousKey, }: { id: string; - time: number; + time: MediaTime; value: number; interpolation?: AnimationInterpolation; previousKey?: ScalarAnimationKey; @@ -190,7 +256,7 @@ function createDiscreteKey({ value, }: { id: string; - time: number; + time: MediaTime; value: string | boolean; }): DiscreteAnimationKey { return { @@ -200,34 +266,20 @@ function createDiscreteKey({ }; } -function getBinding({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): AnimationBindingInstance | undefined { - return animations?.bindings[propertyPath]; +function isDiscreteChannel( + channel: AnimationChannel | undefined, +): channel is DiscreteAnimationChannel { + return channel != null && !isScalarChannel(channel); } -function getChannelById({ +function getChannelData({ animations, - channelId, + propertyPath, }: { animations: ElementAnimations | undefined; - channelId: string; -}): AnimationChannel | undefined { - return animations?.channels[channelId]; -} - -function getBindingComponent({ - binding, - componentKey, -}: { - binding: AnimationBindingInstance; - componentKey: string; -}) { - return binding.components.find((component) => component.key === componentKey) ?? null; + propertyPath: AnimationPath; +}): ChannelData | undefined { + return animations?.[propertyPath]; } function getTargetKeyMetadata({ @@ -236,7 +288,7 @@ function getTargetKeyMetadata({ keyframeId, }: { channel: AnimationChannel | undefined; - time: number; + time: MediaTime; keyframeId?: string; }) { const normalizedChannel = @@ -275,12 +327,12 @@ function upsertDiscreteChannelKey({ keyframeId, }: { channel: DiscreteAnimationChannel | undefined; - time: number; + time: MediaTime; value: string | boolean; keyframeId?: string; }): DiscreteAnimationChannel { - const normalizedChannel = normalizeChannel({ - channel: channel ?? { kind: "discrete", keys: [] }, + const normalizedChannel = normalizeDiscreteChannel({ + channel: channel ?? { keys: [] }, }); const keys = [...normalizedChannel.keys]; if (keyframeId) { @@ -291,8 +343,8 @@ function upsertDiscreteChannelKey({ time, value, }); - return normalizeChannel({ - channel: { kind: "discrete", keys }, + return normalizeDiscreteChannel({ + channel: { keys }, }); } } @@ -306,8 +358,8 @@ function upsertDiscreteChannelKey({ time: keys[existingAtTimeIndex].time, value, }); - return normalizeChannel({ - channel: { kind: "discrete", keys }, + return normalizeDiscreteChannel({ + channel: { keys }, }); } @@ -318,8 +370,8 @@ function upsertDiscreteChannelKey({ value, }), ); - return normalizeChannel({ - channel: { kind: "discrete", keys }, + return normalizeDiscreteChannel({ + channel: { keys }, }); } @@ -332,14 +384,14 @@ function upsertScalarChannelKey({ keyframeId, }: { channel: ScalarAnimationChannel | undefined; - time: number; + time: MediaTime; value: number; interpolation?: AnimationInterpolation; defaultInterpolation?: AnimationInterpolation; keyframeId?: string; }): ScalarAnimationChannel { - const normalizedChannel = normalizeChannel({ - channel: channel ?? { kind: "scalar", keys: [] }, + const normalizedChannel = normalizeScalarChannel({ + channel: channel ?? { keys: [] }, }); const keys = [...normalizedChannel.keys]; if (keyframeId) { @@ -358,9 +410,8 @@ function upsertScalarChannelKey({ } : keys[existingIndex], }); - return normalizeChannel({ + return normalizeScalarChannel({ channel: { - kind: "scalar", keys, extrapolation: normalizedChannel.extrapolation, }, @@ -385,9 +436,8 @@ function upsertScalarChannelKey({ } : keys[existingAtTimeIndex], }); - return normalizeChannel({ + return normalizeScalarChannel({ channel: { - kind: "scalar", keys, extrapolation: normalizedChannel.extrapolation, }, @@ -402,9 +452,8 @@ function upsertScalarChannelKey({ interpolation: interpolation ?? defaultInterpolation, }), ); - return normalizeChannel({ + return normalizeScalarChannel({ channel: { - kind: "scalar", keys, extrapolation: normalizedChannel.extrapolation, }, @@ -418,10 +467,11 @@ export function getChannel({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; }): AnimationChannel | undefined { - const binding = getBinding({ animations, propertyPath }); - const primaryChannelId = - binding != null ? getPrimaryChannelId({ binding }) : null; - return primaryChannelId ? animations?.channels[primaryChannelId] : undefined; + const data = getChannelData({ animations, propertyPath }); + if (isLeafChannelData(data)) { + return data; + } + return getChannelsFromData({ data })[0]; } export function upsertPathKeyframe({ @@ -431,19 +481,17 @@ export function upsertPathKeyframe({ value, interpolation, keyframeId, - kind, - defaultInterpolation, + channelLayout, coerceValue, }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + channelLayout: ParamChannelLayout; + coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null; }): ElementAnimations | undefined { const coercedValue = coerceValue({ value }); if (coercedValue === null) { @@ -451,71 +499,78 @@ export function upsertPathKeyframe({ } const nextAnimations = cloneAnimationsState({ animations }); - const existingBinding = getBinding({ - animations, - propertyPath, - }); - const binding = - existingBinding && existingBinding.kind === kind - ? cloneAnimationBinding({ binding: existingBinding }) - : createAnimationBinding({ path: propertyPath, kind }); - const primaryChannel = getChannel({ - animations, - propertyPath, + const currentData = getChannelData({ animations, propertyPath }); + const primaryChannel = getPrimaryChannelFromData({ + data: currentData, + channelLayout, }); const targetKey = getTargetKeyMetadata({ channel: primaryChannel, time, keyframeId, }); - const componentValues = decomposeAnimationValue({ - kind, + const componentValues = decomposeChannelLayoutValue({ + channelLayout, value: coercedValue, }); if (!componentValues) { return animations; } - const explicitInterpolation = - interpolation != null - ? getInterpolationForBinding({ kind, interpolation }) - : undefined; - const validatedDefaultInterpolation = getInterpolationForBinding({ - kind, - interpolation: defaultInterpolation, - }); - nextAnimations.bindings[propertyPath] = binding; - for (const component of binding.components) { - const nextValue = componentValues[component.key]; + let nextData: ChannelData | undefined = currentData; + for (const component of getLayoutComponents({ channelLayout })) { + const componentKey = component.key; + const nextValue = componentValues[componentKey]; if (nextValue == null) { continue; } - const currentChannel = getChannelById({ - animations, - channelId: component.channelId, + const currentChannel = getChannelFromData({ + data: currentData, + componentKey, + }); + if (component.valueKind === "discrete") { + if (typeof nextValue !== "string" && typeof nextValue !== "boolean") { + continue; + } + const nextChannel = upsertDiscreteChannelKey({ + channel: isDiscreteChannel(currentChannel) ? currentChannel : undefined, + time: targetKey.time, + value: nextValue, + keyframeId: targetKey.id, + }); + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: nextChannel, + }); + continue; + } + + if (typeof nextValue !== "number") { + continue; + } + const nextChannel = upsertScalarChannelKey({ + channel: + currentChannel != null && isScalarChannel(currentChannel) + ? currentChannel + : undefined, + time: targetKey.time, + value: nextValue, + interpolation: + interpolation != null + ? getInterpolationForComponent({ component, interpolation }) + : undefined, + defaultInterpolation: component.defaultInterpolation, + keyframeId: targetKey.id, + }); + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: nextChannel, }); - const targetChannel = - currentChannel?.kind === getBindingChannelKind({ kind }) - ? currentChannel - : createEmptyChannelForBindingKind({ kind }); - nextAnimations.channels[component.channelId] = - targetChannel.kind === "discrete" - ? upsertDiscreteChannelKey({ - channel: targetChannel, - time: targetKey.time, - value: nextValue as string | boolean, - keyframeId: targetKey.id, - }) - : upsertScalarChannelKey({ - channel: targetChannel, - time: targetKey.time, - value: nextValue as number, - interpolation: explicitInterpolation, - defaultInterpolation: validatedDefaultInterpolation, - keyframeId: targetKey.id, - }); } + nextAnimations[propertyPath] = nextData; return toAnimation({ animations: nextAnimations, @@ -530,8 +585,8 @@ export function upsertKeyframe({ keyframeId, }: { channel: AnimationChannel | undefined; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; }): AnimationChannel | undefined { @@ -539,13 +594,9 @@ export function upsertKeyframe({ return undefined; } - if (channel.kind === "discrete") { - if (typeof value !== "string" && typeof value !== "boolean") { - return channel; - } - + if (typeof value === "string" || typeof value === "boolean") { return upsertDiscreteChannelKey({ - channel, + channel: isDiscreteChannel(channel) ? channel : undefined, time, value, keyframeId, @@ -557,7 +608,7 @@ export function upsertKeyframe({ } return upsertScalarChannelKey({ - channel, + channel: isScalarChannel(channel) ? channel : undefined, time, value, interpolation, @@ -576,16 +627,30 @@ export function removeKeyframe({ return undefined; } + if (isScalarChannel(channel)) { + const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); + if (nextKeys.length === 0) { + return undefined; + } + + return normalizeScalarChannel({ + channel: { + ...channel, + keys: nextKeys, + }, + }); + } + const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); if (nextKeys.length === 0) { return undefined; } - return normalizeChannel({ + return normalizeDiscreteChannel({ channel: { ...channel, keys: nextKeys, - } as AnimationChannel, + }, }); } @@ -596,12 +661,34 @@ export function retimeKeyframe({ }: { channel: AnimationChannel | undefined; keyframeId: string; - time: number; + time: MediaTime; }): AnimationChannel | undefined { if (!channel) { return undefined; } + if (isScalarChannel(channel)) { + const keyframeByIdIndex = channel.keys.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeByIdIndex < 0) { + return channel; + } + + const nextKeys = [...channel.keys]; + nextKeys[keyframeByIdIndex] = { + ...nextKeys[keyframeByIdIndex], + time, + }; + + return normalizeScalarChannel({ + channel: { + ...channel, + keys: nextKeys, + }, + }); + } + const keyframeByIdIndex = channel.keys.findIndex( (keyframe) => keyframe.id === keyframeId, ); @@ -615,11 +702,11 @@ export function retimeKeyframe({ time, }; - return normalizeChannel({ + return normalizeDiscreteChannel({ channel: { ...channel, keys: nextKeys, - } as AnimationChannel, + }, }); } @@ -632,26 +719,10 @@ export function setChannel({ propertyPath: AnimationPath; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - if (binding.components.length !== 1) { - throw new Error( - `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, - ); - } - - const primaryComponent = getPrimaryComponent({ binding }); - if (!primaryComponent) { - return animations; - } - return setBindingComponentChannel({ animations, propertyPath, - componentKey: primaryComponent.key, + componentKey: "value", channel, }); } @@ -667,37 +738,14 @@ export function setBindingComponentChannel({ componentKey: string; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const component = getBindingComponent({ - binding, - componentKey, - }); - if (!component) { - return animations; - } - const nextAnimations = cloneAnimationsState({ animations }); - if (!channel || !hasChannelKeys({ channel })) { - delete nextAnimations.channels[component.channelId]; - const hasRemainingKeys = binding.components.some((candidate) => - hasChannelKeys({ - channel: nextAnimations.channels[candidate.channelId], - }), - ); - if (!hasRemainingKeys) { - delete nextAnimations.bindings[propertyPath]; - } - return toAnimation({ - animations: nextAnimations, - }); - } - - nextAnimations.channels[component.channelId] = normalizeChannel({ - channel, + nextAnimations[propertyPath] = setChannelInData({ + data: nextAnimations[propertyPath], + componentKey, + channel: + channel && hasChannelKeys({ channel }) + ? normalizeChannel({ channel }) + : undefined, }); return toAnimation({ animations: nextAnimations, @@ -717,24 +765,11 @@ export function updateScalarKeyframeCurve({ keyframeId: string; patch: ScalarCurveKeyframePatch; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const component = getBindingComponent({ - binding, + const channel = getChannelFromData({ + data: getChannelData({ animations, propertyPath }), componentKey, }); - if (!component) { - return animations; - } - - const channel = getChannelById({ - animations, - channelId: component.channelId, - }); - if (channel?.kind !== "scalar") { + if (!channel || !isScalarChannel(channel)) { return animations; } @@ -764,13 +799,40 @@ export function updateScalarKeyframeCurve({ propertyPath, componentKey, channel: { - kind: "scalar", keys: nextKeys, extrapolation: channel.extrapolation, }, }); } +function cloneChannelWithKeyIds({ + channel, + keyIdMap, +}: { + channel: AnimationChannel; + keyIdMap: Map<string, string>; +}): AnimationChannel { + return isScalarChannel(channel) + ? normalizeScalarChannel({ + channel: { + ...channel, + keys: channel.keys.map((key) => ({ + ...key, + id: keyIdMap.get(key.id) ?? key.id, + })), + }, + }) + : normalizeDiscreteChannel({ + channel: { + ...channel, + keys: channel.keys.map((key) => ({ + ...key, + id: keyIdMap.get(key.id) ?? key.id, + })), + }, + }); +} + export function cloneAnimations({ animations, shouldRegenerateKeyframeIds = false, @@ -783,23 +845,11 @@ export function cloneAnimations({ } const nextAnimations = cloneAnimationsState({ animations }); - nextAnimations.bindings = Object.fromEntries( - Object.entries(animations.bindings).map(([path, binding]) => [ - path, - binding ? cloneAnimationBinding({ binding }) : binding, - ]), - ); - nextAnimations.channels = {}; - - for (const binding of Object.values(nextAnimations.bindings)) { - if (!binding) { - continue; - } - - const primaryChannel = getChannelById({ - animations, - channelId: getPrimaryChannelId({ binding }) ?? "", - }); + for (const [propertyPath, data] of Object.entries(animations).filter(([key]) => + isAnimationStorageKey({ key }), + )) { + const channels = getChannelsFromData({ data }); + const primaryChannel = channels[0]; const keyIdMap = new Map<string, string>(); if (primaryChannel) { for (const key of primaryChannel.keys) { @@ -810,24 +860,22 @@ export function cloneAnimations({ } } - for (const component of binding.components) { - const currentChannel = getChannelById({ - animations, - channelId: component.channelId, - }); - if (!currentChannel) { - continue; - } - - nextAnimations.channels[component.channelId] = normalizeChannel({ - channel: { - ...currentChannel, - keys: currentChannel.keys.map((key) => ({ - ...key, - id: keyIdMap.get(key.id) ?? key.id, - })), - } as AnimationChannel, + if (isLeafChannelData(data)) { + nextAnimations[propertyPath] = cloneChannelWithKeyIds({ + channel: data, + keyIdMap, }); + continue; + } + if (isCompositeChannelData(data)) { + nextAnimations[propertyPath] = Object.fromEntries( + Object.entries(data).map(([componentKey, channel]) => [ + componentKey, + channel + ? cloneChannelWithKeyIds({ channel, keyIdMap }) + : undefined, + ]), + ); } } @@ -841,7 +889,7 @@ export function clampAnimationsToDuration({ duration, }: { animations: ElementAnimations | undefined; - duration: number; + duration: MediaTime; }): ElementAnimations | undefined { if (!animations || duration <= 0) { return undefined; @@ -877,7 +925,7 @@ function splitDiscreteChannelAtTime({ shouldIncludeSplitBoundary, }: { channel: DiscreteAnimationChannel | undefined; - splitTime: number; + splitTime: MediaTime; leftBoundaryId: string; rightBoundaryId: string; shouldIncludeSplitBoundary: boolean; @@ -893,7 +941,10 @@ function splitDiscreteChannelAtTime({ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let rightKeys = normalizedChannel.keys .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); + .map((key) => ({ + ...key, + time: subMediaTime({ a: key.time, b: splitTime }), + })); if (shouldIncludeSplitBoundary) { const hasBoundaryOnLeft = leftKeys.some((key) => @@ -913,7 +964,7 @@ function splitDiscreteChannelAtTime({ createDiscreteKey({ id: leftBoundaryId, time: splitTime, - value: boundaryValue as string | boolean, + value: boundaryValue, }), ]; } @@ -921,8 +972,8 @@ function splitDiscreteChannelAtTime({ rightKeys = [ createDiscreteKey({ id: rightBoundaryId, - time: 0, - value: boundaryValue as string | boolean, + time: ZERO_MEDIA_TIME, + value: boundaryValue, }), ...rightKeys, ]; @@ -931,10 +982,10 @@ function splitDiscreteChannelAtTime({ return { leftChannel: leftKeys.length - ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } }) + ? normalizeChannel({ channel: { keys: leftKeys } }) : undefined, rightChannel: rightKeys.length - ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } }) + ? normalizeChannel({ channel: { keys: rightKeys } }) : undefined, }; } @@ -947,7 +998,7 @@ function splitScalarChannelAtTime({ shouldIncludeSplitBoundary, }: { channel: ScalarAnimationChannel | undefined; - splitTime: number; + splitTime: MediaTime; leftBoundaryId: string; rightBoundaryId: string; shouldIncludeSplitBoundary: boolean; @@ -963,7 +1014,10 @@ function splitScalarChannelAtTime({ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let rightKeys = normalizedChannel.keys .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); + .map((key) => ({ + ...key, + time: subMediaTime({ a: key.time, b: splitTime }), + })); const hasBoundaryOnLeft = leftKeys.some((key) => isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), @@ -976,7 +1030,6 @@ function splitScalarChannelAtTime({ leftChannel: leftKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: leftKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -985,7 +1038,6 @@ function splitScalarChannelAtTime({ rightChannel: rightKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: rightKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1010,7 +1062,7 @@ function splitScalarChannelAtTime({ channel: normalizedChannel, time: splitTime, fallbackValue: leftKey.value, - }) as number; + }); if (leftKey.segmentToNext === "bezier") { const rightHandle = @@ -1043,7 +1095,7 @@ function splitScalarChannelAtTime({ { ...leftKey, rightHandle: { - dt: q0.x - p0.x, + dt: roundMediaTime({ time: q0.x - p0.x }), dv: q0.y - p0.y, }, }, @@ -1052,7 +1104,7 @@ function splitScalarChannelAtTime({ time: splitTime, value: boundaryValue, leftHandle: { - dt: r0.x - splitPoint.x, + dt: roundMediaTime({ time: r0.x - splitPoint.x }), dv: r0.y - splitPoint.y, }, segmentToNext: leftKey.segmentToNext, @@ -1062,10 +1114,10 @@ function splitScalarChannelAtTime({ rightKeys = [ { id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue, rightHandle: { - dt: r1.x - splitPoint.x, + dt: roundMediaTime({ time: r1.x - splitPoint.x }), dv: r1.y - splitPoint.y, }, segmentToNext: "bezier", @@ -1073,9 +1125,9 @@ function splitScalarChannelAtTime({ }, { ...rightKey, - time: rightKey.time - splitTime, + time: subMediaTime({ a: rightKey.time, b: splitTime }), leftHandle: { - dt: q2.x - p3.x, + dt: roundMediaTime({ time: q2.x - p3.x }), dv: q2.y - p3.y, }, }, @@ -1083,7 +1135,7 @@ function splitScalarChannelAtTime({ .filter((key) => key.time > rightKey.time) .map((key) => ({ ...key, - time: key.time - splitTime, + time: subMediaTime({ a: key.time, b: splitTime }), })), ]; } else { @@ -1099,7 +1151,7 @@ function splitScalarChannelAtTime({ rightKeys = [ createScalarKey({ id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue, interpolation: getScalarSegmentInterpolation({ segment: leftKey.segmentToNext, @@ -1112,14 +1164,12 @@ function splitScalarChannelAtTime({ return { leftChannel: normalizeChannel({ channel: { - kind: "scalar", keys: leftKeys, extrapolation: normalizedChannel.extrapolation, }, }), rightChannel: normalizeChannel({ channel: { - kind: "scalar", keys: rightKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1131,7 +1181,6 @@ function splitScalarChannelAtTime({ leftChannel: leftKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: leftKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1140,7 +1189,6 @@ function splitScalarChannelAtTime({ rightChannel: rightKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: rightKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1149,13 +1197,44 @@ function splitScalarChannelAtTime({ }; } +function splitChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: AnimationChannel | undefined; + splitTime: MediaTime; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + return channel != null && !isScalarChannel(channel) + ? splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }) + : splitScalarChannelAtTime({ + channel: + channel != null && isScalarChannel(channel) ? channel : undefined, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }); +} + export function splitAnimationsAtTime({ animations, splitTime, shouldIncludeSplitBoundary = true, }: { animations: ElementAnimations | undefined; - splitTime: number; + splitTime: MediaTime; shouldIncludeSplitBoundary?: boolean; }): { leftAnimations: ElementAnimations | undefined; @@ -1168,55 +1247,39 @@ export function splitAnimationsAtTime({ const leftAnimations = cloneAnimationsState({ animations: undefined }); const rightAnimations = cloneAnimationsState({ animations: undefined }); - for (const [propertyPath, binding] of Object.entries(animations.bindings)) { - if (!binding) { + for (const [propertyPath, data] of Object.entries(animations).filter(([key]) => + isAnimationStorageKey({ key }), + )) { + if (!data) { continue; } - const leftBinding = cloneAnimationBinding({ binding }); - const rightBinding = cloneAnimationBinding({ binding }); const leftBoundaryId = generateUUID(); const rightBoundaryId = generateUUID(); - let hasLeftKeys = false; - let hasRightKeys = false; - for (const component of binding.components) { - const channel = getChannelById({ - animations, - channelId: component.channelId, + for (const [componentKey, channel] of getChannelDataEntries({ data })) { + const splitResult = splitChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, }); - const splitResult = - channel?.kind === "discrete" - ? splitDiscreteChannelAtTime({ - channel, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, - }) - : splitScalarChannelAtTime({ - channel: channel as ScalarAnimationChannel | undefined, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, - }); if (splitResult.leftChannel) { - leftAnimations.channels[component.channelId] = splitResult.leftChannel; - hasLeftKeys = true; + leftAnimations[propertyPath] = setChannelInData({ + data: leftAnimations[propertyPath], + componentKey, + channel: splitResult.leftChannel, + }); } if (splitResult.rightChannel) { - rightAnimations.channels[component.channelId] = splitResult.rightChannel; - hasRightKeys = true; + rightAnimations[propertyPath] = setChannelInData({ + data: rightAnimations[propertyPath], + componentKey, + channel: splitResult.rightChannel, + }); } } - - if (hasLeftKeys) { - leftAnimations.bindings[propertyPath] = leftBinding; - } - if (hasRightKeys) { - rightAnimations.bindings[propertyPath] = rightBinding; - } } return { @@ -1234,28 +1297,24 @@ export function removeElementKeyframe({ propertyPath: AnimationPath; keyframeId: string; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { + const data = getChannelData({ animations, propertyPath }); + if (!data) { return animations; } const nextAnimations = cloneAnimationsState({ animations }); - for (const component of binding.components) { - nextAnimations.channels[component.channelId] = removeKeyframe({ - channel: nextAnimations.channels[component.channelId], - keyframeId, - }); - } - const hasRemainingKeys = binding.components.some((component) => - hasChannelKeys({ - channel: nextAnimations.channels[component.channelId], - }), - ); - if (!hasRemainingKeys) { - delete nextAnimations.bindings[propertyPath]; - for (const component of binding.components) { - delete nextAnimations.channels[component.channelId]; + if (isLeafChannelData(data)) { + nextAnimations[propertyPath] = removeKeyframe({ channel: data, keyframeId }); + } else if (isCompositeChannelData(data)) { + let nextData: ChannelData | undefined = data; + for (const [componentKey, channel] of Object.entries(data)) { + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: removeKeyframe({ channel, keyframeId }), + }); } + nextAnimations[propertyPath] = nextData; } return toAnimation({ animations: nextAnimations, @@ -1271,20 +1330,30 @@ export function retimeElementKeyframe({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; keyframeId: string; - time: number; + time: MediaTime; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { + const data = getChannelData({ animations, propertyPath }); + if (!data) { return animations; } const nextAnimations = cloneAnimationsState({ animations }); - for (const component of binding.components) { - nextAnimations.channels[component.channelId] = retimeKeyframe({ - channel: nextAnimations.channels[component.channelId], + if (isLeafChannelData(data)) { + nextAnimations[propertyPath] = retimeKeyframe({ + channel: data, keyframeId, time, }); + } else if (isCompositeChannelData(data)) { + let nextData: ChannelData | undefined = data; + for (const [componentKey, channel] of Object.entries(data)) { + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: retimeKeyframe({ channel, keyframeId, time }), + }); + } + nextAnimations[propertyPath] = nextData; } return toAnimation({ animations: nextAnimations, diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts index 19685cea..331dcbaa 100644 --- a/apps/web/src/animation/resolve.ts +++ b/apps/web/src/animation/resolve.ts @@ -1,15 +1,13 @@ import type { AnimationPath, - AnimationValueForPath, ElementAnimations, } from "@/animation/types"; -import { - type AnimationComponentValue, - composeAnimationValue, - decomposeAnimationValue, -} from "./binding-values"; +import { formatLinearRgba, parseColorToLinearRgba } from "@/params"; +import type { ParamValue } from "@/params"; +import { isCompositeChannelData, isLeafChannelData } from "./channel-data"; import { getChannelValueAtTime, + isScalarChannel, } from "./interpolation"; export function getElementLocalTime({ @@ -33,47 +31,123 @@ export function getElementLocalTime({ return localTime; } -export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({ +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: number; +}): number; +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: string; +}): string; +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: boolean; +}): boolean; +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: ParamValue; +}): ParamValue; +export function resolveAnimationPathValueAtTime({ animations, propertyPath, localTime, fallbackValue, }: { animations: ElementAnimations | undefined; - propertyPath: TPath; + propertyPath: AnimationPath; localTime: number; - fallbackValue: AnimationValueForPath<TPath>; -}): AnimationValueForPath<TPath> { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + fallbackValue: ParamValue; +}): ParamValue { + const data = animations?.[propertyPath]; + if (!data) { + return fallbackValue; + } + if (isLeafChannelData(data)) { + if (typeof fallbackValue === "number") { + return getChannelValueAtTime({ + channel: isScalarChannel(data) ? data : undefined, + time: localTime, + fallbackValue, + }); + } + if (typeof fallbackValue === "string" || typeof fallbackValue === "boolean") { + return getChannelValueAtTime({ + channel: !isScalarChannel(data) ? data : undefined, + time: localTime, + fallbackValue, + }); + } + return fallbackValue; + } + if (!isCompositeChannelData(data)) { return fallbackValue; } - const fallbackComponents = decomposeAnimationValue({ - kind: binding.kind, - value: fallbackValue, - }); - if (!fallbackComponents) { + if ( + typeof fallbackValue !== "string" || + !("r" in data) || + !("g" in data) || + !("b" in data) || + !("a" in data) + ) { return fallbackValue; } - const componentValues = Object.fromEntries( - binding.components.map((component) => { - const channel = animations?.channels[component.channelId]; - return [ - component.key, - getChannelValueAtTime({ - channel, - time: localTime, - fallbackValue: - fallbackComponents[component.key] ?? - (channel?.kind === "discrete" ? false : 0), - }), - ]; - }), - ) as Record<string, AnimationComponentValue | undefined>; - return (composeAnimationValue({ - binding, - componentValues, - }) ?? fallbackValue) as AnimationValueForPath<TPath>; + const fallbackComponents = parseColorToLinearRgba({ color: fallbackValue }); + if (fallbackComponents === null) { + return fallbackValue; + } + + return formatLinearRgba({ + color: { + r: getChannelValueAtTime({ + channel: data.r && isScalarChannel(data.r) ? data.r : undefined, + time: localTime, + fallbackValue: fallbackComponents.r, + }), + g: getChannelValueAtTime({ + channel: data.g && isScalarChannel(data.g) ? data.g : undefined, + time: localTime, + fallbackValue: fallbackComponents.g, + }), + b: getChannelValueAtTime({ + channel: data.b && isScalarChannel(data.b) ? data.b : undefined, + time: localTime, + fallbackValue: fallbackComponents.b, + }), + a: getChannelValueAtTime({ + channel: data.a && isScalarChannel(data.a) ? data.a : undefined, + time: localTime, + fallbackValue: fallbackComponents.a, + }), + }, + }); } diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts index 7e241994..9b5bd0c7 100644 --- a/apps/web/src/animation/types.ts +++ b/apps/web/src/animation/types.ts @@ -1,3 +1,6 @@ +import type { MediaTime } from "@/wasm"; +import type { ParamValue } from "@/params"; + export const ANIMATION_PROPERTY_PATHS = [ "transform.positionX", "transform.positionY", @@ -18,10 +21,7 @@ export const ANIMATION_PROPERTY_PATHS = [ export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; export type GraphicParamPath = `params.${string}`; export type EffectParamPath = `effects.${string}.params.${string}`; -export type AnimationPath = - | AnimationPropertyPath - | GraphicParamPath - | EffectParamPath; +export type AnimationPath = string; export const ANIMATION_PROPERTY_GROUPS = { "transform.scale": ["transform.scaleX", "transform.scaleY"], @@ -29,44 +29,21 @@ export const ANIMATION_PROPERTY_GROUPS = { export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; -export type VectorValue = { x: number; y: number }; export type DiscreteValue = boolean | string; -export type AnimationValue = number | string | boolean | VectorValue; -export interface AnimationPropertyValueMap { - "transform.positionX": number; - "transform.positionY": number; - "transform.scaleX": number; - "transform.scaleY": number; - "transform.rotate": number; - opacity: number; - volume: number; - color: string; - "background.color": string; - "background.paddingX": number; - "background.paddingY": number; - "background.offsetX": number; - "background.offsetY": number; - "background.cornerRadius": number; -} -export type DynamicAnimationPathValue = number | string | boolean; export interface NumericSpec { min?: number; max?: number; step?: number; } -export type AnimationValueForPath<TPath extends AnimationPath> = - TPath extends AnimationPropertyPath - ? AnimationPropertyValueMap[TPath] - : TPath extends GraphicParamPath | EffectParamPath - ? DynamicAnimationPathValue - : never; -export type AnimationNumericPropertyPath = { - [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never; -}[AnimationPropertyPath]; -export type AnimationColorPropertyPath = { - [K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never; -}[AnimationPropertyPath]; +export type AnimationColorPropertyPath = Extract< + AnimationPropertyPath, + "color" | "background.color" +>; +export type AnimationNumericPropertyPath = Exclude< + AnimationPropertyPath, + AnimationColorPropertyPath +>; export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; export type DiscreteKeyframeInterpolation = "hold"; @@ -74,20 +51,18 @@ export type AnimationInterpolation = | ContinuousKeyframeInterpolation | DiscreteKeyframeInterpolation; -export type PrimitiveAnimationChannelKind = "scalar" | "discrete"; -export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete"; export type ScalarSegmentType = "step" | "linear" | "bezier"; export type TangentMode = "auto" | "aligned" | "broken" | "flat"; export type ChannelExtrapolationMode = "hold" | "linear"; export interface CurveHandle { - dt: number; + dt: MediaTime; dv: number; } -interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> { +interface BaseAnimationKeyframe<TValue extends ParamValue> { id: string; - time: number; // relative to element start time + time: MediaTime; // relative to element start time value: TValue; } @@ -98,13 +73,16 @@ export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> { tangentMode: TangentMode; } -export interface DiscreteAnimationKey - extends BaseAnimationKeyframe<DiscreteValue> {} +export type DiscreteAnimationKey = BaseAnimationKeyframe<DiscreteValue>; -export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey; +export type Keyframe<TValue extends ParamValue = ParamValue> = + TValue extends number + ? ScalarAnimationKey + : TValue extends DiscreteValue + ? DiscreteAnimationKey + : never; -export interface ScalarAnimationChannel { - kind: "scalar"; +export interface ScalarChannel { keys: ScalarAnimationKey[]; extrapolation?: { before: ChannelExtrapolationMode; @@ -112,72 +90,26 @@ export interface ScalarAnimationChannel { }; } -export interface DiscreteAnimationChannel { - kind: "discrete"; +export interface DiscreteChannel { keys: DiscreteAnimationKey[]; } -export type AnimationChannel = - | ScalarAnimationChannel - | DiscreteAnimationChannel; - -export type ElementAnimationChannelMap = Record< - string, - AnimationChannel | undefined ->; - -export interface AnimationBindingComponent<TKey extends string = string> { - key: TKey; - channelId: string; -} - -interface BaseAnimationBinding< - TKind extends AnimationBindingKind, - TComponentKey extends string, -> { - path: AnimationPath; - kind: TKind; - components: AnimationBindingComponent<TComponentKey>[]; -} - -export interface NumberAnimationBinding - extends BaseAnimationBinding<"number", "value"> {} - -export interface Vector2AnimationBinding - extends BaseAnimationBinding<"vector2", "x" | "y"> {} - -export interface ColorAnimationBinding - extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> { - colorSpace: "srgb-linear"; -} - -export interface DiscreteAnimationBinding - extends BaseAnimationBinding<"discrete", "value"> {} - -export type AnimationBindingInstance = - | NumberAnimationBinding - | Vector2AnimationBinding - | ColorAnimationBinding - | DiscreteAnimationBinding; - -export interface AnimationBindingByKind { - number: NumberAnimationBinding; - vector2: Vector2AnimationBinding; - color: ColorAnimationBinding; - discrete: DiscreteAnimationBinding; -} +export type Channel<TValue extends ParamValue = ParamValue> = + TValue extends number + ? ScalarChannel + : TValue extends DiscreteValue + ? DiscreteChannel + : never; -export type AnimationBindingOfKind<TKind extends AnimationBindingKind> = - AnimationBindingByKind[TKind]; +export type ScalarAnimationChannel = Channel<number>; +export type DiscreteAnimationChannel = Channel<DiscreteValue>; +export type AnimationChannel = Channel; -export type ElementAnimationBindingMap = Record< - string, - AnimationBindingInstance | undefined ->; +export type CompositeChannelData = Record<string, AnimationChannel | undefined>; +export type ChannelData = AnimationChannel | CompositeChannelData; export interface ElementAnimations { - bindings: ElementAnimationBindingMap; - channels: ElementAnimationChannelMap; + [propertyPath: AnimationPath]: ChannelData | undefined; } export type NormalizedCubicBezier = [number, number, number, number]; @@ -185,7 +117,6 @@ export type NormalizedCubicBezier = [number, number, number, number]; export interface ScalarGraphChannelTarget { propertyPath: AnimationPath; componentKey: string; - channelId: string; } export interface ScalarGraphChannel extends ScalarGraphChannelTarget { @@ -213,8 +144,8 @@ export interface ScalarCurveKeyframePatch { export interface ElementKeyframe { propertyPath: AnimationPath; id: string; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation: AnimationInterpolation; } diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx index 94e3f3e1..9728cbc7 100644 --- a/apps/web/src/app/brand/page.tsx +++ b/apps/web/src/app/brand/page.tsx @@ -190,7 +190,7 @@ export default function BrandPage() { </div> <div className="flex flex-col gap-3"> - <h2 className="font-semibold text-lg">What's not allowed</h2> + <h2 className="font-semibold text-lg">What'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't sell or share your data with anyone (we don'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're responsible for how you use it - don't break + the law + </li> + <li> + Service provided "as is" - we can'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're responsible for how you use OpenCut and the content you + create. Don'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. + "as is" without warranties. While we strive for + reliability, we can'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'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'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'll notify you of significant changes to these terms</li> <li>Continued use means you accept any updates</li> <li>You can always self-host an older version if you prefer</li> <li>Major changes will be discussed with the community on GitHub</li> @@ -203,7 +207,7 @@ export default function TermsPage() { <a href={`${SOCIAL_LINKS.github}/issues`} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > GitHub repository @@ -219,7 +223,7 @@ export default function TermsPage() { <a href={SOCIAL_LINKS.x} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > X (Twitter) diff --git a/apps/web/src/clipboard/handlers/elements.ts b/apps/web/src/clipboard/handlers/elements.ts index b2f5f2c3..2297b493 100644 --- a/apps/web/src/clipboard/handlers/elements.ts +++ b/apps/web/src/clipboard/handlers/elements.ts @@ -35,7 +35,7 @@ export const ElementsClipboardHandler = { }; }, - paste(entry, context) { + paste({ entry, context }) { if (entry.items.length === 0) { return null; } diff --git a/apps/web/src/clipboard/handlers/index.ts b/apps/web/src/clipboard/handlers/index.ts index 29d4d1d0..a042c3dd 100644 --- a/apps/web/src/clipboard/handlers/index.ts +++ b/apps/web/src/clipboard/handlers/index.ts @@ -45,5 +45,5 @@ export function buildPasteClipboardCommand({ const handler = clipboardHandlers[entry.type] as ClipboardHandler< typeof entry.type >; - return handler.paste(entry as never, context); + return handler.paste({ entry: entry as never, context }); } diff --git a/apps/web/src/clipboard/handlers/keyframes.ts b/apps/web/src/clipboard/handlers/keyframes.ts index ad156e12..dde8384f 100644 --- a/apps/web/src/clipboard/handlers/keyframes.ts +++ b/apps/web/src/clipboard/handlers/keyframes.ts @@ -1,4 +1,6 @@ import { getKeyframeById } from "@/animation"; +import { getChannelEntriesFromData } from "@/animation/channel-data"; +import { isScalarChannel } from "@/animation/interpolation"; import type { SelectedKeyframeRef } from "@/animation/types"; import type { TimelineElement } from "@/timeline"; import { PasteKeyframesCommand } from "@/commands/timeline"; @@ -7,6 +9,7 @@ import type { KeyframeClipboardCurvePatch, KeyframeClipboardItem, } from "../types"; +import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm"; function resolveSingleSourceElement({ selectedKeyframes, @@ -40,14 +43,13 @@ function getCurvePatches({ propertyPath: KeyframeClipboardItem["propertyPath"]; keyframeId: string; }): KeyframeClipboardCurvePatch[] { - const binding = element.animations?.bindings[propertyPath]; - if (!binding) { + const data = element.animations?.[propertyPath]; + if (!data) { return []; } - return binding.components.flatMap((component) => { - const channel = element.animations?.channels[component.channelId]; - if (channel?.kind !== "scalar") { + return getChannelEntriesFromData({ data }).flatMap(([componentKey, channel]) => { + if (!channel || !isScalarChannel(channel)) { return []; } @@ -60,7 +62,7 @@ function getCurvePatches({ return [ { - componentKey: component.key, + componentKey, patch: { leftHandle: keyframe.leftHandle ?? null, rightHandle: keyframe.rightHandle ?? null, @@ -80,7 +82,7 @@ function buildClipboardItem({ element: TimelineElement; propertyPath: KeyframeClipboardItem["propertyPath"]; keyframeId: string; -}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null { +}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null { const keyframe = getKeyframeById({ animations: element.animations, propertyPath, @@ -136,10 +138,11 @@ export const KeyframesClipboardHandler = { } const minTime = Math.min(...rawItems.map((item) => item.time)); + const minTimeMedia = roundMediaTime({ time: minTime }); const items = rawItems .map(({ time, ...item }) => ({ ...item, - timeOffset: time - minTime, + timeOffset: subMediaTime({ a: time, b: minTimeMedia }), })) .sort( (left, right) => @@ -154,7 +157,10 @@ export const KeyframesClipboardHandler = { }; }, - paste(entry, { selectedElements, time }) { + paste({ + entry, + context: { selectedElements, time }, + }) { const targetElement = selectedElements[0]; if (!targetElement || entry.items.length === 0) { return null; diff --git a/apps/web/src/clipboard/types.ts b/apps/web/src/clipboard/types.ts index 0d249c9d..24ca9105 100644 --- a/apps/web/src/clipboard/types.ts +++ b/apps/web/src/clipboard/types.ts @@ -2,16 +2,17 @@ import type { EditorCore } from "@/core"; import type { AnimationInterpolation, AnimationPath, - AnimationValue, ScalarCurveKeyframePatch, SelectedKeyframeRef, } from "@/animation/types"; +import type { ParamValue } from "@/params"; import type { Command } from "@/commands/base-command"; import type { CreateTimelineElement, ElementRef, TrackType, } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface ElementClipboardItem { trackId: string; @@ -26,8 +27,8 @@ export interface KeyframeClipboardCurvePatch { export interface KeyframeClipboardItem { propertyPath: AnimationPath; - timeOffset: number; - value: AnimationValue; + timeOffset: MediaTime; + value: ParamValue; interpolation: AnimationInterpolation; curvePatches: KeyframeClipboardCurvePatch[]; } @@ -61,17 +62,17 @@ export interface PasteContext { editor: EditorCore; selectedElements: ElementRef[]; selectedKeyframes: SelectedKeyframeRef[]; - time: number; + time: MediaTime; } export interface ClipboardHandler<TType extends ClipboardEntryType> { type: TType; canCopy(context: CopyContext): boolean; copy(context: CopyContext): ClipboardEntryByType[TType] | null; - paste( - entry: ClipboardEntryByType[TType], - context: PasteContext, - ): Command | null; + paste(args: { + entry: ClipboardEntryByType[TType]; + context: PasteContext; + }): Command | null; } export type ClipboardHandlerMap = { diff --git a/apps/web/src/commands/media/add-media-asset.ts b/apps/web/src/commands/media/add-media-asset.ts index d22241a2..629bc3ee 100644 --- a/apps/web/src/commands/media/add-media-asset.ts +++ b/apps/web/src/commands/media/add-media-asset.ts @@ -16,14 +16,22 @@ export class AddMediaAssetCommand extends Command { private previousProjectFps: FrameRate | null = null; private appliedProjectFps: FrameRate | null = null; - constructor( - private projectId: string, - private asset: Omit<MediaAsset, "id">, - ) { + constructor({ + projectId, + asset, + }: { + projectId: string; + asset: Omit<MediaAsset, "id">; + }) { super(); + this.projectId = projectId; + this.asset = asset; this.assetId = generateUUID(); } + private projectId: string; + private asset: Omit<MediaAsset, "id">; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedAssets = [...editor.media.getAssets()]; @@ -117,7 +125,14 @@ export class AddMediaAssetCommand extends Command { const activeProject = editor.project.getActiveOrNull(); if (!activeProject) return; - if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return; + if ( + !this.appliedProjectFps || + !frameRatesEqual({ + a: activeProject.settings.fps, + b: this.appliedProjectFps, + }) + ) + return; const highestRemainingVideoFps = getHighestImportedVideoFps({ mediaAssets: editor.media.getAssets(), diff --git a/apps/web/src/commands/media/remove-media-asset.ts b/apps/web/src/commands/media/remove-media-asset.ts index 8da62181..38ff79fe 100644 --- a/apps/web/src/commands/media/remove-media-asset.ts +++ b/apps/web/src/commands/media/remove-media-asset.ts @@ -13,13 +13,21 @@ export class RemoveMediaAssetCommand extends Command { private savedTracks: SceneTracks | null = null; private removedAsset: MediaAsset | null = null; - constructor( - private projectId: string, - private assetId: string, - ) { + constructor({ + projectId, + assetId, + }: { + projectId: string; + assetId: string; + }) { super(); + this.projectId = projectId; + this.assetId = assetId; } + private projectId: string; + private assetId: string; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const assets = editor.media.getAssets(); diff --git a/apps/web/src/commands/scene/create-scene.ts b/apps/web/src/commands/scene/create-scene.ts index b554fe90..3776467d 100644 --- a/apps/web/src/commands/scene/create-scene.ts +++ b/apps/web/src/commands/scene/create-scene.ts @@ -7,13 +7,21 @@ export class CreateSceneCommand extends Command { private savedScenes: TScene[] | null = null; private createdScene: TScene | null = null; - constructor( - private name: string, - private isMain: boolean = false, - ) { + constructor({ + name, + isMain = false, + }: { + name: string; + isMain?: boolean; + }) { super(); + this.name = name; + this.isMain = isMain; } + private name: string; + private isMain: boolean; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedScenes = [...editor.scenes.getScenes()]; diff --git a/apps/web/src/commands/scene/move-bookmark.ts b/apps/web/src/commands/scene/move-bookmark.ts index a1624eb0..45b7bda5 100644 --- a/apps/web/src/commands/scene/move-bookmark.ts +++ b/apps/web/src/commands/scene/move-bookmark.ts @@ -3,17 +3,26 @@ import { EditorCore } from "@/core"; import type { TScene } from "@/timeline"; import { updateSceneInArray } from "@/timeline/scenes"; import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class MoveBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - constructor( - private fromTime: number, - private toTime: number, - ) { + constructor({ + fromTime, + toTime, + }: { + fromTime: MediaTime; + toTime: MediaTime; + }) { super(); + this.fromTime = fromTime; + this.toTime = toTime; } + private fromTime: MediaTime; + private toTime: MediaTime; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeScene = editor.scenes.getActiveScene(); diff --git a/apps/web/src/commands/scene/remove-bookmark.ts b/apps/web/src/commands/scene/remove-bookmark.ts index ea592556..95403e0e 100644 --- a/apps/web/src/commands/scene/remove-bookmark.ts +++ b/apps/web/src/commands/scene/remove-bookmark.ts @@ -6,12 +6,13 @@ import { getFrameTime, removeBookmarkFromArray, } from "@/timeline/bookmarks/index"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export class RemoveBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - private frameTime: number = 0; + private frameTime: MediaTime = ZERO_MEDIA_TIME; - constructor(private time: number) { + constructor(private time: MediaTime) { super(); } diff --git a/apps/web/src/commands/scene/rename-scene.ts b/apps/web/src/commands/scene/rename-scene.ts index 19d6bfd8..8bc8724b 100644 --- a/apps/web/src/commands/scene/rename-scene.ts +++ b/apps/web/src/commands/scene/rename-scene.ts @@ -7,13 +7,21 @@ export class RenameSceneCommand extends Command { private savedScenes: TScene[] | null = null; private previousName: string | null = null; - constructor( - private sceneId: string, - private newName: string, - ) { + constructor({ + sceneId, + newName, + }: { + sceneId: string; + newName: string; + }) { super(); + this.sceneId = sceneId; + this.newName = newName; } + private sceneId: string; + private newName: string; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const scenes = editor.scenes.getScenes(); diff --git a/apps/web/src/commands/scene/toggle-bookmark.ts b/apps/web/src/commands/scene/toggle-bookmark.ts index 4193c76e..31751be4 100644 --- a/apps/web/src/commands/scene/toggle-bookmark.ts +++ b/apps/web/src/commands/scene/toggle-bookmark.ts @@ -6,12 +6,13 @@ import { getFrameTime, toggleBookmarkInArray, } from "@/timeline/bookmarks/index"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export class ToggleBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - private frameTime: number = 0; + private frameTime: MediaTime = ZERO_MEDIA_TIME; - constructor(private time: number) { + constructor(private time: MediaTime) { super(); } diff --git a/apps/web/src/commands/scene/update-bookmark.ts b/apps/web/src/commands/scene/update-bookmark.ts index 0d6a0193..7208abc4 100644 --- a/apps/web/src/commands/scene/update-bookmark.ts +++ b/apps/web/src/commands/scene/update-bookmark.ts @@ -6,17 +6,26 @@ import { getFrameTime, updateBookmarkInArray, } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class UpdateBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - constructor( - private time: number, - private updates: Partial<Omit<Bookmark, "time">>, - ) { + constructor({ + time, + updates, + }: { + time: MediaTime; + updates: Partial<Omit<Bookmark, "time">>; + }) { super(); + this.time = time; + this.updates = updates; } + private time: MediaTime; + private updates: Partial<Omit<Bookmark, "time">>; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeScene = editor.scenes.getActiveScene(); diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts index c19bd526..a587ef14 100644 --- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -10,6 +10,13 @@ import type { SceneTracks, TimelineElement } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; import { generateUUID } from "@/utils/id"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; function pasteKeyframesIntoElement({ element, @@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({ clipboardItems, }: { element: TimelineElement; - time: number; + time: MediaTime; clipboardItems: KeyframeClipboardItem[]; }): TimelineElement { let nextElement = element; @@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({ continue; } - const keyframeTime = Math.max( - 0, - Math.min(time + item.timeOffset, nextElement.duration), - ); + const keyframeTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ + a: addMediaTime({ a: time, b: item.timeOffset }), + b: nextElement.duration, + }), + }); const nextAnimations = upsertPathKeyframe({ animations: nextElement.animations, propertyPath: item.propertyPath, @@ -42,8 +52,7 @@ function pasteKeyframesIntoElement({ value: item.value, interpolation: item.interpolation, keyframeId: generateUUID(), - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, + channelLayout: target.channelLayout, coerceValue: target.coerceValue, }); const pastedKeyframe = getKeyframeAtTime({ @@ -79,7 +88,7 @@ export class PasteKeyframesCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly time: number; + private readonly time: MediaTime; private readonly clipboardItems: KeyframeClipboardItem[]; constructor({ @@ -90,7 +99,7 @@ export class PasteKeyframesCommand extends Command { }: { trackId: string; elementId: string; - time: number; + time: MediaTime; clipboardItems: KeyframeClipboardItem[]; }) { super(); diff --git a/apps/web/src/commands/timeline/clipboard/paste.ts b/apps/web/src/commands/timeline/clipboard/paste.ts index 4523566e..be0c2b91 100644 --- a/apps/web/src/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/commands/timeline/clipboard/paste.ts @@ -13,18 +13,25 @@ import { enforceMainTrackStart, } from "@/timeline/placement"; import { cloneAnimations } from "@/animation"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class PasteCommand extends Command { private savedState: SceneTracks | null = null; private pastedElements: { trackId: string; elementId: string }[] = []; - private readonly time: number; + private readonly time: MediaTime; private readonly clipboardItems: ElementClipboardItem[]; constructor({ time, clipboardItems, }: { - time: number; + time: MediaTime; clipboardItems: ElementClipboardItem[]; }) { super(); @@ -39,8 +46,12 @@ export class PasteCommand extends Command { this.savedState = editor.scenes.getActiveScene().tracks; this.pastedElements = []; - const minStart = Math.min( - ...this.clipboardItems.map((item) => item.element.startTime), + const minStart = this.clipboardItems.reduce( + (earliestStartTime, item) => + item.element.startTime < earliestStartTime + ? item.element.startTime + : earliestStartTime, + this.clipboardItems[0].element.startTime, ); let updatedTracks = this.savedState; @@ -97,12 +108,18 @@ export class PasteCommand extends Command { targetTrackId: targetTrack.id, requestedStartTime: earliestElement.startTime, }); - const delta = adjustedEarliestStartTime - earliestElement.startTime; + const delta = subMediaTime({ + a: adjustedEarliestStartTime, + b: earliestElement.startTime, + }); - if (delta !== 0) { + if (delta !== ZERO_MEDIA_TIME) { elementsForPlacement = elementsToAdd.map((element) => ({ ...element, - startTime: Math.max(0, element.startTime + delta), + startTime: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: element.startTime, b: delta }), + }), })); } } @@ -168,14 +185,20 @@ function buildPastedElements({ time, }: { items: ElementClipboardItem[]; - minStart: number; - time: number; + minStart: MediaTime; + time: MediaTime; }): TimelineElement[] { const elementsToAdd: TimelineElement[] = []; for (const item of items) { - const relativeOffset = item.element.startTime - minStart; - const startTime = Math.max(0, time + relativeOffset); + const relativeOffset = subMediaTime({ + a: item.element.startTime, + b: minStart, + }); + const startTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: time, b: relativeOffset }), + }); const newElementId = generateUUID(); elementsToAdd.push({ diff --git a/apps/web/src/commands/timeline/element/duplicate-elements.ts b/apps/web/src/commands/timeline/element/duplicate-elements.ts index 8f65f7c7..2b1ab4ea 100644 --- a/apps/web/src/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/commands/timeline/element/duplicate-elements.ts @@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement"; import { cloneAnimations } from "@/animation"; +import type { MediaTime } from "@/wasm"; interface DuplicateElementsParams { elements: { trackId: string; elementId: string }[]; @@ -119,7 +120,7 @@ function buildDuplicateElement({ }: { element: TimelineElement; id: string; - startTime: number; + startTime: MediaTime; }): TimelineElement { return { ...element, diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts index 49ac6f2d..dfde0ffe 100644 --- a/apps/web/src/commands/timeline/element/insert-element.ts +++ b/apps/web/src/commands/timeline/element/insert-element.ts @@ -18,6 +18,7 @@ import { resolveTrackPlacement, validateElementTrackCompatibility, } from "@/timeline/placement"; +import { roundMediaTime } from "@/wasm"; type InsertElementPlacement = | { mode: "explicit"; trackId: string } @@ -188,7 +189,7 @@ export class InsertElementCommand extends Command { } } - if (element.type === "text" && !element.content) { + if (element.type === "text" && !element.params.content) { console.error("Text element must have content"); return false; } @@ -266,7 +267,12 @@ export class InsertElementCommand extends Command { placementResult.kind === "existingTrack" ? { ...element, - startTime: placementResult.adjustedStartTime ?? element.startTime, + startTime: + placementResult.adjustedStartTime !== undefined + ? roundMediaTime({ + time: placementResult.adjustedStartTime, + }) + : element.startTime, } : element; diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts index 07abe2be..ad12e680 100644 --- a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts @@ -5,7 +5,8 @@ import { } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; -import type { AnimationPath, AnimationValue } from "@/animation/types"; +import type { AnimationPath } from "@/animation/types"; +import type { ParamValue } from "@/params"; import type { SceneTracks, TimelineElement } from "@/timeline"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; @@ -18,7 +19,7 @@ function removeKeyframeAndPersist({ element: TimelineElement; propertyPath: AnimationPath; keyframeId: string; - valueAtPlayhead: AnimationValue | null; + valueAtPlayhead: ParamValue | null; }): TimelineElement { const target = resolveAnimationTarget({ element, path: propertyPath }); if (!target) { @@ -52,7 +53,7 @@ export class RemoveKeyframeCommand extends Command { private readonly elementId: string; private readonly propertyPath: AnimationPath; private readonly keyframeId: string; - private readonly valueAtPlayhead: AnimationValue | null; + private readonly valueAtPlayhead: ParamValue | null; constructor({ trackId, @@ -65,7 +66,7 @@ export class RemoveKeyframeCommand extends Command { elementId: string; propertyPath: AnimationPath; keyframeId: string; - valueAtPlayhead: AnimationValue | null; + valueAtPlayhead: ParamValue | null; }) { super(); this.trackId = trackId; diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts index aa04e2bc..94054372 100644 --- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts @@ -4,6 +4,12 @@ import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; export class RetimeKeyframeCommand extends Command { @@ -12,7 +18,7 @@ export class RetimeKeyframeCommand extends Command { private readonly elementId: string; private readonly propertyPath: AnimationPath; private readonly keyframeId: string; - private readonly nextTime: number; + private readonly nextTime: MediaTime; constructor({ trackId, @@ -25,7 +31,7 @@ export class RetimeKeyframeCommand extends Command { elementId: string; propertyPath: AnimationPath; keyframeId: string; - nextTime: number; + nextTime: MediaTime; }) { super(); this.trackId = trackId; @@ -48,7 +54,10 @@ export class RetimeKeyframeCommand extends Command { return element; } - const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.nextTime, b: element.duration }), + }); return { ...element, animations: retimeElementKeyframe({ diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index 7bd1b2e5..babe1ad0 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -9,6 +9,12 @@ import { isVisualElement } from "@/timeline/element-utils"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationInterpolation } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class UpsertEffectParamKeyframeCommand extends Command { private savedState: SceneTracks | null = null; @@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { private readonly elementId: string; private readonly effectId: string; private readonly paramKey: string; - private readonly time: number; + private readonly time: MediaTime; private readonly value: number | string | boolean; private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; @@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementId: string; effectId: string; paramKey: string; - time: number; + time: MediaTime; value: number | string | boolean; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementId: this.elementId, elementPredicate: isVisualElement, update: (element) => { - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.time, b: element.duration }), + }); const propertyPath = buildEffectParamPath({ effectId: this.effectId, paramKey: this.paramKey, @@ -81,8 +90,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, + channelLayout: target.channelLayout, coerceValue: target.coerceValue, }); return { ...element, animations }; diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts index 15c18bfc..8ba13a11 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -7,16 +7,22 @@ import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationPath, AnimationInterpolation, - AnimationValue, } from "@/animation/types"; +import type { ParamValue } from "@/params"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class UpsertKeyframeCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly propertyPath: AnimationPath; - private readonly time: number; - private readonly value: AnimationValue; + private readonly time: MediaTime; + private readonly value: ParamValue; private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; @@ -32,8 +38,8 @@ export class UpsertKeyframeCommand extends Command { trackId: string; elementId: string; propertyPath: AnimationPath; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; }) { @@ -64,7 +70,10 @@ export class UpsertKeyframeCommand extends Command { return element; } - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.time, b: element.duration }), + }); return { ...element, animations: upsertPathKeyframe({ @@ -74,8 +83,7 @@ export class UpsertKeyframeCommand extends Command { value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, + channelLayout: target.channelLayout, coerceValue: target.coerceValue, }), }; diff --git a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts index 1445b1f8..fb02554d 100644 --- a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts +++ b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts @@ -1,22 +1,22 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; import { - getCustomMaskClosedStateAfterPointRemoval, - removeCustomMaskPoints, -} from "@/masks/custom-path"; -import type { CustomMask } from "@/masks/types"; + getFreeformPathClosedStateAfterPointRemoval, + removeFreeformPathPoints, +} from "@/masks/freeform/path"; +import type { FreeformPathMask } from "@/masks/types"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline"; -function deletePointsFromCustomMask({ +function deletePointsFromFreeformPathMask({ mask, pointIds, }: { - mask: CustomMask; + mask: FreeformPathMask; pointIds: string[]; -}): CustomMask { +}): FreeformPathMask { const points = mask.params.path; - const nextPoints = removeCustomMaskPoints({ points, pointIds }); + const nextPoints = removeFreeformPathPoints({ points, pointIds }); if (nextPoints.length === points.length) { return mask; } @@ -26,7 +26,7 @@ function deletePointsFromCustomMask({ params: { ...mask.params, path: nextPoints, - closed: getCustomMaskClosedStateAfterPointRemoval({ + closed: getFreeformPathClosedStateAfterPointRemoval({ wasClosed: mask.params.closed, remainingPointCount: nextPoints.length, }), @@ -46,11 +46,11 @@ function deletePointsFromElementMask({ const currentMasks = element.masks ?? []; let didDeletePoints = false; const nextMasks = currentMasks.map((mask) => { - if (mask.id !== maskId || mask.type !== "custom") { + if (mask.id !== maskId || mask.type !== "freeform") { return mask; } - const nextMask = deletePointsFromCustomMask({ + const nextMask = deletePointsFromFreeformPathMask({ mask, pointIds, }); @@ -64,7 +64,7 @@ function deletePointsFromElementMask({ }; } -export class DeleteCustomMaskPointsCommand extends Command { +export class DeleteFreeformPathMaskPointsCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; @@ -100,8 +100,9 @@ export class DeleteCustomMaskPointsCommand extends Command { elementId: this.elementId, elementPredicate: isMaskableElement, update: (element) => { + if (!isMaskableElement(element)) return element; const result = deletePointsFromElementMask({ - element: element as MaskableElement, + element, maskId: this.maskId, pointIds: this.pointIds, }); diff --git a/apps/web/src/commands/timeline/element/masks/index.ts b/apps/web/src/commands/timeline/element/masks/index.ts index dd6373b1..70b4333e 100644 --- a/apps/web/src/commands/timeline/element/masks/index.ts +++ b/apps/web/src/commands/timeline/element/masks/index.ts @@ -1,4 +1,4 @@ -export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points"; -export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point"; +export { DeleteFreeformPathMaskPointsCommand } from "./delete-custom-mask-points"; +export { InsertFreeformPathMaskPointCommand } from "./insert-custom-mask-point"; export { RemoveMaskCommand } from "./remove-mask"; export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts index 58559af6..0f714884 100644 --- a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts +++ b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts @@ -1,23 +1,23 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; -import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom"; +import { insertPointOnFreeformSegment } from "@/masks/freeform/definition"; import type { ElementBounds } from "@/preview/element-bounds"; -import type { CustomMask } from "@/masks/types"; +import type { FreeformPathMask } from "@/masks/types"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline"; -function insertPointIntoCustomMask({ +function insertPointIntoFreeformPathMask({ mask, segmentIndex, canvasPoint, bounds, }: { - mask: CustomMask; + mask: FreeformPathMask; segmentIndex: number; canvasPoint: { x: number; y: number }; bounds: ElementBounds; -}): { mask: CustomMask; insertedPointId: string | null } { - const result = insertPointOnCustomMaskSegment({ +}): { mask: FreeformPathMask; insertedPointId: string | null } { + const result = insertPointOnFreeformSegment({ params: mask.params, segmentIndex, canvasPoint, @@ -61,11 +61,11 @@ function insertPointIntoElementMask({ let didInsertPoint = false; const nextMasks = currentMasks.map((mask) => { - if (mask.id !== maskId || mask.type !== "custom") { + if (mask.id !== maskId || mask.type !== "freeform") { return mask; } - const result = insertPointIntoCustomMask({ + const result = insertPointIntoFreeformPathMask({ mask, segmentIndex, canvasPoint, @@ -85,7 +85,7 @@ function insertPointIntoElementMask({ }; } -export class InsertCustomMaskPointCommand extends Command { +export class InsertFreeformPathMaskPointCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; @@ -130,8 +130,9 @@ export class InsertCustomMaskPointCommand extends Command { elementId: this.elementId, elementPredicate: isMaskableElement, update: (element) => { + if (!isMaskableElement(element)) return element; const result = insertPointIntoElementMask({ - element: element as MaskableElement, + element, maskId: this.maskId, segmentIndex: this.segmentIndex, canvasPoint: this.canvasPoint, diff --git a/apps/web/src/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/commands/timeline/element/masks/remove-mask.ts index b543bce1..b92d1d8c 100644 --- a/apps/web/src/commands/timeline/element/masks/remove-mask.ts +++ b/apps/web/src/commands/timeline/element/masks/remove-mask.ts @@ -45,11 +45,13 @@ export class RemoveMaskCommand extends Command { trackId: this.trackId, elementId: this.elementId, elementPredicate: isMaskableElement, - update: (element) => - removeMaskFromElement({ - element: element as MaskableElement, + update: (element) => { + if (!isMaskableElement(element)) return element; + return removeMaskFromElement({ + element, maskId: this.maskId, - }), + }); + }, }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts index fec9b923..b53e24ca 100644 --- a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts +++ b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -56,11 +56,13 @@ export class ToggleMaskInvertedCommand extends Command { trackId: this.trackId, elementId: this.elementId, elementPredicate: isMaskableElement, - update: (element) => - toggleMaskInvertedOnElement({ - element: element as MaskableElement, + update: (element) => { + if (!isMaskableElement(element)) return element; + return toggleMaskInvertedOnElement({ + element, maskId: this.maskId, - }), + }); + }, }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts index 11c317f4..0a551fc3 100644 --- a/apps/web/src/commands/timeline/element/split-elements.ts +++ b/apps/web/src/commands/timeline/element/split-elements.ts @@ -9,12 +9,18 @@ import { EditorCore } from "@/core"; import { isRetimableElement } from "@/timeline"; import { splitAnimationsAtTime } from "@/animation"; import { getSourceSpanAtClipTime } from "@/retime"; +import { + addMediaTime, + type MediaTime, + roundMediaTime, + subMediaTime, +} from "@/wasm"; export class SplitElementsCommand extends Command { private savedState: SceneTracks | null = null; private rightSideElements: { trackId: string; elementId: string }[] = []; private readonly elements: { trackId: string; elementId: string }[]; - private readonly splitTime: number; + private readonly splitTime: MediaTime; private readonly retainSide: "both" | "left" | "right"; constructor({ @@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command { retainSide = "both", }: { elements: { trackId: string; elementId: string }[]; - splitTime: number; + splitTime: MediaTime; retainSide?: "both" | "left" | "right"; }) { super(); @@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command { return [element]; } - const relativeTime = this.splitTime - element.startTime; + const relativeTime = subMediaTime({ + a: this.splitTime, + b: element.startTime, + }); const leftVisibleDuration = relativeTime; - const rightVisibleDuration = element.duration - relativeTime; + const rightVisibleDuration = subMediaTime({ + a: element.duration, + b: relativeTime, + }); const retimeRef = isRetimableElement(element) ? element.retime : undefined; - const leftSourceSpan = getSourceSpanAtClipTime({ - clipTime: leftVisibleDuration, - retime: retimeRef, + // Snap the source-side split point exactly once and derive the right + // half from it. Independently rounding both spans (left and total) + // would let a 1-tick rounding error desynchronise them, breaking the + // invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`. + // See the same discipline in `compute-resize.ts` (snap-once comment). + const leftSourceSpan = roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: leftVisibleDuration, + retime: retimeRef, + }), + }); + const totalSourceSpan = roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: element.duration, + retime: retimeRef, + }), }); - const totalSourceSpan = getSourceSpanAtClipTime({ - clipTime: element.duration, - retime: retimeRef, + const rightSourceSpan = subMediaTime({ + a: totalSourceSpan, + b: leftSourceSpan, }); - const rightSourceSpan = totalSourceSpan - leftSourceSpan; const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ animations: element.animations, splitTime: relativeTime, @@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command { }); let splitResult: TimelineElement[]; + const leftTrimEnd = addMediaTime({ + a: element.trimEnd, + b: rightSourceSpan, + }); + const rightTrimStart = addMediaTime({ + a: element.trimStart, + b: leftSourceSpan, + }); + if (this.retainSide === "left") { splitResult = [ { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightSourceSpan, + trimEnd: leftTrimEnd, name: `${element.name} (left)`, animations: leftAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), @@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command { id: newId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftSourceSpan, + trimStart: rightTrimStart, name: `${element.name} (right)`, animations: rightAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), }, ]; } else { - // "both" - split into two pieces const secondElementId = generateUUID(); this.rightSideElements.push({ trackId: track.id, @@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command { { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightSourceSpan, + trimEnd: leftTrimEnd, name: `${element.name} (left)`, animations: leftAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), @@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command { id: secondElementId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftSourceSpan, + trimStart: rightTrimStart, name: `${element.name} (right)`, animations: rightAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), diff --git a/apps/web/src/commands/timeline/track/add-track.ts b/apps/web/src/commands/timeline/track/add-track.ts index 6b58b687..90becf13 100644 --- a/apps/web/src/commands/timeline/track/add-track.ts +++ b/apps/web/src/commands/timeline/track/add-track.ts @@ -11,14 +11,22 @@ export class AddTrackCommand extends Command { private trackId: string; private savedState: SceneTracks | null = null; - constructor( - private type: TrackType, - private index?: number, - ) { + constructor({ + type, + index, + }: { + type: TrackType; + index?: number; + }) { super(); + this.type = type; + this.index = index; this.trackId = generateUUID(); } + private type: TrackType; + private index?: number; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.scenes.getActiveScene().tracks; diff --git a/apps/web/src/commands/timeline/tracks-snapshot.ts b/apps/web/src/commands/timeline/tracks-snapshot.ts index 5b6d871a..8e55e602 100644 --- a/apps/web/src/commands/timeline/tracks-snapshot.ts +++ b/apps/web/src/commands/timeline/tracks-snapshot.ts @@ -3,13 +3,21 @@ import type { SceneTracks } from "@/timeline"; import { EditorCore } from "@/core"; export class TracksSnapshotCommand extends Command { - constructor( - private before: SceneTracks, - private after: SceneTracks, - ) { + constructor({ + before, + after, + }: { + before: SceneTracks; + after: SceneTracks; + }) { super(); + this.before = before; + this.after = after; } + private before: SceneTracks; + private after: SceneTracks; + execute(): CommandResult | undefined { EditorCore.getInstance().timeline.updateTracks(this.after); return undefined; diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 276cb490..aaae79a9 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -1,15 +1,24 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm"; +import { + formatTimecode, + type FrameRate, + type TimeCodeFormat, +} from "opencut-wasm"; import { cn } from "@/utils/ui"; +import { + parseMediaTimecode, + snapSeekMediaTime, + type MediaTime, +} from "@/wasm"; interface EditableTimecodeProps { - time: number; - duration: number; + time: MediaTime; + duration: MediaTime; format?: TimeCodeFormat; fps: FrameRate; - onTimeChange?: ({ time }: { time: number }) => void; + onTimeChange?: ({ time }: { time: MediaTime }) => void; className?: string; disabled?: boolean; } @@ -46,7 +55,11 @@ export function EditableTimecode({ }; const applyEdit = () => { - const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps }); + const parsedTime = parseMediaTimecode({ + timeCode: inputValue, + format, + fps, + }); if (parsedTime == null) { setHasError(true); @@ -54,7 +67,7 @@ export function EditableTimecode({ } const clampedTime = duration - ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime) + ? snapSeekMediaTime({ time: parsedTime, duration, fps }) : parsedTime; onTimeChange?.({ time: clampedTime }); diff --git a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx index 1539d305..b412de7b 100644 --- a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx +++ b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx @@ -93,7 +93,7 @@ interface AssetsPanelStore { setMediaViewMode: (mode: MediaViewMode) => void; mediaSortBy: MediaSortKey; mediaSortOrder: MediaSortOrder; - setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void; + setMediaSort: (args: { key: MediaSortKey; order: MediaSortOrder }) => void; } export const useAssetsPanelStore = create<AssetsPanelStore>()( @@ -109,7 +109,7 @@ export const useAssetsPanelStore = create<AssetsPanelStore>()( setMediaViewMode: (mode) => set({ mediaViewMode: mode }), mediaSortBy: "name", mediaSortOrder: "asc", - setMediaSort: (key, order) => + setMediaSort: ({ key, order }) => set({ mediaSortBy: key, mediaSortOrder: order }), }), { diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 7cf89fbe..8cfb99c3 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -1,240 +1,241 @@ -"use client"; - -import { Plus } from "lucide-react"; -import { type ReactNode, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { AspectRatio } from "@/components/ui/aspect-ratio"; -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useEditor } from "@/editor/use-editor"; -import type { TimelineDragData } from "@/timeline/drag"; -import { cn } from "@/utils/ui"; - -export interface DraggableItemProps { - name: string; - preview: ReactNode; - dragData: TimelineDragData; - onDragStart?: ({ e }: { e: React.DragEvent }) => void; - onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; - aspectRatio?: number; - className?: string; - containerClassName?: string; - shouldShowPlusOnDrag?: boolean; - shouldShowLabel?: boolean; - isRounded?: boolean; - variant?: "card" | "compact"; - isDraggable?: boolean; -} - -export function DraggableItem({ - name, - preview, - dragData, - onDragStart, - onAddToTimeline, - aspectRatio = 16 / 9, - className = "", - containerClassName, - shouldShowPlusOnDrag = true, - shouldShowLabel = true, - isRounded = true, - variant = "card", - isDraggable = true, -}: DraggableItemProps) { - const [isDragging, setIsDragging] = useState(false); - const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); - const dragRef = useRef<HTMLDivElement>(null); - const editor = useEditor(); - - const handleAddToTimeline = () => { - onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); - }; - - const emptyImg = new window.Image(); - emptyImg.src = - "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; - - useEffect(() => { - if (!isDragging) return; - - const handleDragOver = (e: DragEvent) => { - setDragPosition({ x: e.clientX, y: e.clientY }); - }; - - document.addEventListener("dragover", handleDragOver); - - return () => { - document.removeEventListener("dragover", handleDragOver); - }; - }, [isDragging]); - - const handleDragStart = (event: React.DragEvent) => { - event.dataTransfer.setDragImage(emptyImg, 0, 0); - - editor.timeline.dragSource.begin({ - dataTransfer: event.dataTransfer, - dragData, - }); - - setDragPosition({ x: event.clientX, y: event.clientY }); - setIsDragging(true); - - onDragStart?.({ e: event }); - }; - - const handleDragEnd = () => { - setIsDragging(false); - editor.timeline.dragSource.end(); - }; - - return ( - <> - {variant === "card" ? ( - <div - ref={dragRef} - className={cn("group relative", containerClassName ?? "w-28")} - > - <div - className={cn( - "relative flex h-auto w-full cursor-default flex-col gap-1 p-", - className, - )} - > - <AspectRatio - ratio={aspectRatio} - className={cn( - "bg-accent relative overflow-hidden", - isRounded && "rounded-sm", - isDraggable && "[&::-webkit-drag-ghost]:opacity-0", - )} - draggable={isDraggable} - onDragStart={isDraggable ? handleDragStart : undefined} - onDragEnd={isDraggable ? handleDragEnd : undefined} - > - {preview} - {!isDragging && ( - <PlusButton - className="opacity-0 group-hover:opacity-100" - onClick={handleAddToTimeline} - /> - )} - </AspectRatio> - {shouldShowLabel && ( - <span - className="text-muted-foreground w-full truncate text-left text-[0.7rem]" - title={name} - > - <span className="sr-only">{name}</span> - <span aria-hidden="true"> - {name.length > 8 - ? `${name.slice(0, 16)}...${name.slice(-3)}` - : name} - </span> - </span> - )} - </div> - </div> - ) : ( - <div - ref={dragRef} - className={cn("group relative w-full", containerClassName)} - > - <button - type="button" - className={cn( - "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none", - isDraggable && "[&::-webkit-drag-ghost]:opacity-0", - className, - )} - draggable={isDraggable} - onDragStart={isDraggable ? handleDragStart : undefined} - onDragEnd={isDraggable ? handleDragEnd : undefined} - > - <div className="size-6 shrink-0 overflow-hidden rounded-sm"> - {preview} - </div> - <span className="w-full flex-1 truncate text-sm text-left"> - {name} - </span> - </button> - </div> - )} - - {isDraggable && - isDragging && - typeof document !== "undefined" && - createPortal( - <div - className="pointer-events-none fixed z-9999" - style={{ - left: dragPosition.x - 40, - top: dragPosition.y - 40, - }} - > - <div className="w-[80px]"> - <AspectRatio - ratio={1} - className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3" - > - <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover"> - {preview} - </div> - {shouldShowPlusOnDrag && ( - <PlusButton - onClick={handleAddToTimeline} - tooltipText="Add to timeline or drag to position" - /> - )} - </AspectRatio> - </div> - </div>, - document.body, - )} - </> - ); -} - -function PlusButton({ - className, - onClick, - tooltipText, -}: { - className?: string; - onClick?: () => void; - tooltipText?: string; -}) { - const button = ( - <Button - size="icon" - className={cn( - "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5", - className, - )} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onClick?.(); - }} - title={tooltipText} - > - <Plus /> - </Button> - ); - - if (tooltipText) { - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent> - <p>{tooltipText}</p> - </TooltipContent> - </Tooltip> - ); - } - - return button; -} +"use client";
+
+import { Plus } from "lucide-react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/editor/use-editor";
+import type { TimelineDragData } from "@/timeline/drag";
+import { cn } from "@/utils/ui";
+import type { MediaTime } from "@/wasm";
+
+export interface DraggableItemProps {
+ name: string;
+ preview: ReactNode;
+ dragData: TimelineDragData;
+ onDragStart?: ({ e }: { e: React.DragEvent }) => void;
+ onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
+ aspectRatio?: number;
+ className?: string;
+ containerClassName?: string;
+ shouldShowPlusOnDrag?: boolean;
+ shouldShowLabel?: boolean;
+ isRounded?: boolean;
+ variant?: "card" | "compact";
+ isDraggable?: boolean;
+}
+
+export function DraggableItem({
+ name,
+ preview,
+ dragData,
+ onDragStart,
+ onAddToTimeline,
+ aspectRatio = 16 / 9,
+ className = "",
+ containerClassName,
+ shouldShowPlusOnDrag = true,
+ shouldShowLabel = true,
+ isRounded = true,
+ variant = "card",
+ isDraggable = true,
+}: DraggableItemProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
+ const dragRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
+ };
+
+ const emptyImg = new window.Image();
+ emptyImg.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleDragOver = (e: DragEvent) => {
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ };
+
+ document.addEventListener("dragover", handleDragOver);
+
+ return () => {
+ document.removeEventListener("dragover", handleDragOver);
+ };
+ }, [isDragging]);
+
+ const handleDragStart = (event: React.DragEvent) => {
+ event.dataTransfer.setDragImage(emptyImg, 0, 0);
+
+ editor.timeline.dragSource.begin({
+ dataTransfer: event.dataTransfer,
+ dragData,
+ });
+
+ setDragPosition({ x: event.clientX, y: event.clientY });
+ setIsDragging(true);
+
+ onDragStart?.({ e: event });
+ };
+
+ const handleDragEnd = () => {
+ setIsDragging(false);
+ editor.timeline.dragSource.end();
+ };
+
+ return (
+ <>
+ {variant === "card" ? (
+ <div
+ ref={dragRef}
+ className={cn("group relative", containerClassName ?? "w-28")}
+ >
+ <div
+ className={cn(
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
+ className,
+ )}
+ >
+ <AspectRatio
+ ratio={aspectRatio}
+ className={cn(
+ "bg-accent relative overflow-hidden",
+ isRounded && "rounded-sm",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ {preview}
+ {!isDragging && (
+ <PlusButton
+ className="opacity-0 group-hover:opacity-100"
+ onClick={handleAddToTimeline}
+ />
+ )}
+ </AspectRatio>
+ {shouldShowLabel && (
+ <span
+ className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
+ title={name}
+ >
+ <span className="sr-only">{name}</span>
+ <span aria-hidden="true">
+ {name.length > 8
+ ? `${name.slice(0, 16)}...${name.slice(-3)}`
+ : name}
+ </span>
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div
+ ref={dragRef}
+ className={cn("group relative w-full", containerClassName)}
+ >
+ <button
+ type="button"
+ className={cn(
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ className,
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
+ {preview}
+ </div>
+ <span className="w-full flex-1 truncate text-sm text-left">
+ {name}
+ </span>
+ </button>
+ </div>
+ )}
+
+ {isDraggable &&
+ isDragging &&
+ typeof document !== "undefined" &&
+ createPortal(
+ <div
+ className="pointer-events-none fixed z-9999"
+ style={{
+ left: dragPosition.x - 40,
+ top: dragPosition.y - 40,
+ }}
+ >
+ <div className="w-[80px]">
+ <AspectRatio
+ ratio={1}
+ className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
+ >
+ <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
+ {preview}
+ </div>
+ {shouldShowPlusOnDrag && (
+ <PlusButton
+ onClick={handleAddToTimeline}
+ tooltipText="Add to timeline or drag to position"
+ />
+ )}
+ </AspectRatio>
+ </div>
+ </div>,
+ document.body,
+ )}
+ </>
+ );
+}
+
+function PlusButton({
+ className,
+ onClick,
+ tooltipText,
+}: {
+ className?: string;
+ onClick?: () => void;
+ tooltipText?: string;
+}) {
+ const button = (
+ <Button
+ size="icon"
+ className={cn(
+ "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
+ className,
+ )}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
+ title={tooltipText}
+ >
+ <Plus />
+ </Button>
+ );
+
+ if (tooltipText) {
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ );
+ }
+
+ return button;
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 4afd22b7..00cb015f 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -26,7 +26,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; import { useEditor } from "@/editor/use-editor"; import { useFileUpload } from "@/media/use-file-upload"; import { invokeAction } from "@/actions"; @@ -139,9 +139,12 @@ export function MediaView() { const handleSort = ({ key }: { key: MediaSortKey }) => { if (mediaSortBy === key) { - setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc"); + setMediaSort({ + key, + order: mediaSortOrder === "asc" ? "desc" : "asc", + }); } else { - setMediaSort(key, "asc"); + setMediaSort({ key, order: "asc" }); } }; @@ -255,11 +258,11 @@ function MediaAssetDraggable({ startTime, }: { asset: MediaAsset; - startTime: number; + startTime: MediaTime; }) => { const duration = asset.duration != null - ? Math.round(asset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: asset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const element = buildElementFromMedia({ mediaId: asset.id, diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx index bf3a1c16..5edbeab3 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { Select, @@ -34,6 +34,10 @@ import type { TCanvasSize } from "@/project/types"; type SettingsView = "project-info" | "background"; +function isSettingsView(value: string): value is SettingsView { + return value === "project-info" || value === "background"; +} + const PRESET_LABELS: Record<string, string> = { "1:1": "1:1", "16:9": "16:9", @@ -73,23 +77,20 @@ function useCanvasDimensionDraft({ value: number; onCommit: (value: number) => void; }) { - const pendingValueRef = useRef(value); - const syncedValueRef = useRef(value); - - if (syncedValueRef.current !== value) { - syncedValueRef.current = value; - pendingValueRef.current = value; - } + const [pendingValue, setPendingValue] = useState(value); return usePropertyDraft({ displayValue: formatCanvasDimension({ value }), parse: (input) => parseCanvasDimension({ input }), + onStartEditing: () => { + setPendingValue(value); + }, onPreview: (nextValue) => { - pendingValueRef.current = nextValue; + setPendingValue(nextValue); }, onCommit: () => { - if (pendingValueRef.current !== value) { - onCommit(pendingValueRef.current); + if (pendingValue !== value) { + onCommit(pendingValue); } }, }); @@ -212,7 +213,14 @@ export function SettingsView() { contentClassName="px-0" scrollClassName="pt-0" actions={ - <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> + <Tabs + value={view} + onValueChange={(value) => { + if (isSettingsView(value)) { + setView(value); + } + }} + > <TabsList> <TabsTrigger value="project-info">Project info</TabsTrigger> <TabsTrigger value="background">Background</TabsTrigger> diff --git a/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx new file mode 100644 index 00000000..2befcef4 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { resolveAnimationPathValueAtTime } from "@/animation"; +import { Section, SectionContent, SectionFields } from "@/components/section"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { useKeyframedParamProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; +import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field"; +import type { ParamValue, ParamValues } from "@/params"; +import { + getElementParams, + readElementParamValue, + writeElementParamValue, + type ElementParamDefinition, +} from "@/params/registry"; +import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; + +export function ElementParamsTab({ + element, + trackId, + paramKeys, + sectionKey, +}: { + element: TimelineElement; + trackId: string; + paramKeys?: readonly string[]; + sectionKey: string; +}) { + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const params = getElementParams({ element }).filter( + (param) => !paramKeys || paramKeys.includes(param.key), + ); + const baseValues = buildValues({ element, params }); + + return ( + <Section sectionKey={`${element.id}:${sectionKey}`}> + <SectionContent className="pt-4"> + <SectionFields> + {params + .filter((param) => isVisible({ param, values: baseValues })) + .map((param) => ( + <ElementParamField + key={param.key} + element={element} + trackId={trackId} + param={param} + baseValue={baseValues[param.key] ?? param.default} + localTime={localTime} + isPlayheadWithinElementRange={isPlayheadWithinElementRange} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + ); +} + +function ElementParamField({ + element, + trackId, + param, + baseValue, + localTime, + isPlayheadWithinElementRange, +}: { + element: TimelineElement; + trackId: string; + param: ElementParamDefinition; + baseValue: ParamValue; + localTime: MediaTime; + isPlayheadWithinElementRange: boolean; +}) { + const resolvedValue = resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath: param.key, + localTime, + fallbackValue: baseValue, + }); + const animatedParam = useKeyframedParamProperty({ + param, + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: param.key, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates: ({ value }) => + writeElementParamValue({ element, param, value }), + }); + + return ( + <PropertyParamField + param={param} + value={resolvedValue} + onPreview={animatedParam.onPreview} + onCommit={animatedParam.onCommit} + keyframe={ + param.keyframable === false + ? undefined + : { + isActive: animatedParam.isKeyframedAtTime, + isDisabled: !isPlayheadWithinElementRange, + onToggle: animatedParam.toggleKeyframe, + } + } + /> + ); +} + +function buildValues({ + element, + params, +}: { + element: TimelineElement; + params: readonly ElementParamDefinition[]; +}): ParamValues { + const values: ParamValues = {}; + for (const param of params) { + const value = readElementParamValue({ element, param }); + if (value !== null) { + values[param.key] = value; + } + } + return values; +} + +function isVisible({ + param, + values, +}: { + param: ElementParamDefinition; + values: ParamValues; +}): boolean { + return (param.dependencies ?? []).every((dependency) => + areParamValuesEqual({ + left: values[dependency.param], + right: dependency.equals, + }), + ); +} + +function areParamValuesEqual({ + left, + right, +}: { + left: ParamValue | undefined; + right: ParamValue; +}): boolean { + return left === right; +} diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx index c9a9c22e..66225903 100644 --- a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx +++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx @@ -1,6 +1,10 @@ "use client"; -import type { ParamDefinition, NumberParamDefinition } from "@/params"; +import type { + ParamDefinition, + NumberParamDefinition, + ParamValue, +} from "@/params"; import { formatNumberForDisplay, getFractionDigitsForStep, @@ -19,6 +23,7 @@ import { } from "@/components/ui/select"; import { usePropertyDraft } from "../hooks/use-property-draft"; import { KeyframeToggle } from "./keyframe-toggle"; +import { Textarea } from "@/components/ui/textarea"; export function PropertyParamField({ param, @@ -28,8 +33,8 @@ export function PropertyParamField({ keyframe, }: { param: ParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; + value: ParamValue; + onPreview: (value: ParamValue) => void; onCommit: () => void; keyframe?: { isActive: boolean; @@ -41,7 +46,7 @@ export function PropertyParamField({ <SectionField label={param.label} beforeLabel={ - keyframe ? ( + keyframe && param.keyframable !== false ? ( <KeyframeToggle isActive={keyframe.isActive} isDisabled={keyframe.isDisabled} @@ -68,8 +73,8 @@ function ParamInput({ onCommit, }: { param: ParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; + value: ParamValue; + onPreview: (value: ParamValue) => void; onCommit: () => void; }) { if (param.type === "number") { @@ -131,6 +136,27 @@ function ParamInput({ ); } + if (param.type === "text") { + return ( + <Textarea + value={String(value)} + onChange={(event) => onPreview(event.currentTarget.value)} + onBlur={onCommit} + /> + ); + } + + if (param.type === "font") { + return ( + <input + className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none" + value={String(value)} + onChange={(event) => onPreview(event.currentTarget.value)} + onBlur={onCommit} + /> + ); + } + return null; } diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 967156b3..83e31aec 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -1,22 +1,25 @@ import { useEditor } from "@/editor/use-editor"; import { getElementLocalTime } from "@/animation"; +import { addMediaTime, mediaTime, type MediaTime } from "@/wasm"; export function useElementPlayhead({ startTime, duration, }: { - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; }) { const playheadTime = useEditor((editor) => editor.playback.getCurrentTime()); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: startTime, - elementDuration: duration, + const localTime = mediaTime({ + ticks: getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: startTime, + elementDuration: duration, + }), }); const isPlayheadWithinElementRange = playheadTime >= startTime && - playheadTime <= startTime + duration; + playheadTime <= addMediaTime({ a: startTime, b: duration }); return { localTime, isPlayheadWithinElementRange }; } diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts deleted file mode 100644 index 80fc171b..00000000 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useEditor } from "@/editor/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, -} from "@/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import { upsertElementKeyframe } from "@/timeline/animation-properties"; - -export function useKeyframedColorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedColor, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedColor: string; - buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>; -}) { - const editor = useEditor(); - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const onChange = ({ color }: { color: string }) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: color, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], - }); - }; - - const onChangeEnd = () => editor.timeline.commitPreview(); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, - ], - }); - }; - - return { - isKeyframedAtTime, - hasAnimatedKeyframes, - keyframeIdAtTime, - onChange, - onChangeEnd, - toggleKeyframe, - }; -} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts deleted file mode 100644 index 32222b7a..00000000 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { useEditor } from "@/editor/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, -} from "@/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import { upsertElementKeyframe } from "@/timeline/animation-properties"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedNumberProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - displayValue, - parse, - valueAtPlayhead, - step, - buildBaseUpdates, - buildAdditionalKeyframes, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - displayValue: string; - parse: (input: string) => number | null; - valueAtPlayhead: number; - step?: number; - buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>; - buildAdditionalKeyframes?: ({ - value, - }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>; -}) { - const editor = useEditor(); - const snapValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; - const updatedAnimations = [ - { propertyPath, value: nextValue }, - ...additionalKeyframes, - ].reduce( - (currentAnimations, keyframe) => - upsertElementKeyframe({ - animations: currentAnimations, - propertyPath: keyframe.propertyPath, - time: localTime, - value: keyframe.value, - }), - animations, - ); - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { animations: updatedAnimations }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - const propertyDraft = usePropertyDraft({ - displayValue, - parse: (input) => { - const parsedValue = parse(input); - return parsedValue === null ? null : snapValue(parsedValue); - }, - onPreview: (value) => previewValue({ value }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: snapValue(valueAtPlayhead), - }, - ], - }); - }; - - const commitValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: nextValue }, - ...additionalKeyframes.map((keyframe) => ({ - trackId, - elementId, - propertyPath: keyframe.propertyPath, - time: localTime, - value: keyframe.value, - })), - ], - }); - return; - } - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId, - patch: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - return { - ...propertyDraft, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitValue, - }; -} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 18e027c7..0a0f7db0 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -8,15 +8,16 @@ import { upsertPathKeyframe, } from "@/animation"; import type { + AnimationPath, ElementAnimations, } from "@/animation/types"; import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamValueKind, -} from "@/animation/animated-params"; -import type { ParamDefinition } from "@/params"; + coerceParamValue, + getParamChannelLayout, + type ParamDefinition, +} from "@/params"; import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface KeyframedParamPropertyResult { hasAnimatedKeyframes: boolean; @@ -32,6 +33,7 @@ export function useKeyframedParamProperty({ trackId, elementId, animations, + propertyPath, localTime, isPlayheadWithinElementRange, resolvedValue, @@ -41,7 +43,8 @@ export function useKeyframedParamProperty({ trackId: string; elementId: string; animations: ElementAnimations | undefined; - localTime: number; + propertyPath?: AnimationPath; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedValue: number | string | boolean; buildBaseUpdates: ({ @@ -51,15 +54,16 @@ export function useKeyframedParamProperty({ }) => Partial<TimelineElement>; }): KeyframedParamPropertyResult { const editor = useEditor(); - const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const resolvedPropertyPath = + propertyPath ?? buildGraphicParamPath({ paramKey: param.key }); const hasAnimatedKeyframes = hasKeyframesForPath({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, }); const keyframeAtTime = isPlayheadWithinElementRange ? getKeyframeAtTime({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, }) : null; @@ -78,15 +82,12 @@ export function useKeyframedParamProperty({ updates: { animations: upsertPathKeyframe({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, value, - kind: getAnimationParamValueKind({ param }), - defaultInterpolation: getAnimationParamDefaultInterpolation({ - param, - }), + channelLayout: getParamChannelLayout({ param }), coerceValue: ({ value: nextValue }) => - coerceAnimationParamValue({ + coerceParamValue({ param, value: nextValue, }), @@ -120,7 +121,7 @@ export function useKeyframedParamProperty({ { trackId, elementId, - propertyPath, + propertyPath: resolvedPropertyPath, keyframeId: keyframeIdAtTime, }, ], @@ -133,7 +134,7 @@ export function useKeyframedParamProperty({ { trackId, elementId, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, value: resolvedValue, }, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts index 0e7ae6a7..7e8f6e40 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -1,4 +1,4 @@ -import { useReducer, useRef } from "react";
+import { useState } from "react";
import { evaluateMathExpression } from "@/utils/math";
function looksLikeExpression({ input }: { input: string }): boolean {
@@ -14,59 +14,56 @@ export function usePropertyDraft<T>({ parse,
onPreview,
onCommit,
+ onStartEditing,
supportsExpressions = true,
}: {
displayValue: string;
parse: (input: string) => T | null;
onPreview: (value: T) => void;
onCommit: () => void;
+ onStartEditing?: () => void;
supportsExpressions?: boolean;
}) {
- const [, forceRender] = useReducer(
- (renderVersion: number) => renderVersion + 1,
- 0,
- );
- const isEditing = useRef(false);
- const draft = useRef("");
+ const [isEditing, setIsEditing] = useState(false);
+ const [draft, setDraft] = useState("");
return {
- displayValue: isEditing.current ? draft.current : sourceDisplay,
+ displayValue: isEditing ? draft : sourceDisplay,
scrubTo: (value: number) => {
const parsed = parse(String(value));
if (parsed !== null) onPreview(parsed);
},
commitScrub: onCommit,
onFocus: () => {
- isEditing.current = true;
- draft.current = sourceDisplay;
- forceRender();
+ setIsEditing(true);
+ setDraft(sourceDisplay);
+ onStartEditing?.();
},
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
- draft.current = event.target.value;
- forceRender();
+ const nextDraft = event.target.value;
+ setDraft(nextDraft);
- const parsed = parse(event.target.value);
+ const parsed = parse(nextDraft);
if (parsed !== null) {
onPreview(parsed);
}
},
- onBlur: () => {
- if (
- supportsExpressions &&
- looksLikeExpression({ input: draft.current })
- ) {
- const evaluated = evaluateMathExpression({ input: draft.current });
+ onBlur: (
+ event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
+ ) => {
+ const nextDraft = event.target.value;
+ if (supportsExpressions && looksLikeExpression({ input: nextDraft })) {
+ const evaluated = evaluateMathExpression({ input: nextDraft });
if (evaluated !== null) {
const parsed = parse(String(evaluated));
if (parsed !== null) onPreview(parsed);
}
}
onCommit();
- isEditing.current = false;
- draft.current = "";
- forceRender();
+ setIsEditing(false);
+ setDraft("");
},
};
}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 52fa42b5..d946027d 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -71,7 +71,12 @@ export function PropertiesPanel() { <Button variant={tab.id === activeTab.id ? "secondary" : "ghost"} size="icon" - onClick={() => setActiveTab(element.type, tab.id)} + onClick={() => + setActiveTab({ + elementType: element.type, + tabId: tab.id, + }) + } aria-label={tab.label} className={cn( "shrink-0", diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index f9e40ab3..1eda6575 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -22,16 +22,43 @@ import { MagicWand05Icon, DashboardSpeed02Icon, } from "@hugeicons/core-free-icons"; -import { TransformTab } from "@/rendering/components/transform-tab"; -import { BlendingTab } from "@/rendering/components/blending-tab"; -import { AudioTab } from "@/timeline/components/audio-tab"; -import { TextTab } from "@/text/components/text-tab"; +import { ElementParamsTab } from "./components/element-params-tab"; import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab"; import { MasksTab } from "@/masks/components/masks-tab"; import { SpeedTab } from "@/speed/components/speed-tab"; import { GraphicTab } from "@/graphics/components/graphic-tab"; import { OcShapesIcon } from "@/components/icons"; +const TRANSFORM_PARAM_KEYS = [ + "transform.positionX", + "transform.positionY", + "transform.scaleX", + "transform.scaleY", + "transform.rotate", +] as const; + +const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const; +const AUDIO_PARAM_KEYS = ["volume", "muted"] as const; +const TEXT_PARAM_KEYS = [ + "content", + "fontFamily", + "fontSize", + "color", + "textAlign", + "fontWeight", + "fontStyle", + "textDecoration", + "letterSpacing", + "lineHeight", + "background.enabled", + "background.color", + "background.cornerRadius", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", +] as const; + export type TabContentProps = { trackId: string; }; @@ -58,7 +85,12 @@ function buildTransformTab({ label: "Transform", icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, content: ({ trackId }) => ( - <TransformTab element={element} trackId={trackId} /> + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={TRANSFORM_PARAM_KEYS} + sectionKey="transform" + /> ), }; } @@ -73,7 +105,12 @@ function buildBlendingTab({ label: "Blending", icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, content: ({ trackId }) => ( - <BlendingTab element={element} trackId={trackId} /> + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={BLENDING_PARAM_KEYS} + sectionKey="blending" + /> ), }; } @@ -87,7 +124,14 @@ function buildAudioTab({ id: "audio", label: "Audio", icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, - content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, + content: ({ trackId }) => ( + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={AUDIO_PARAM_KEYS} + sectionKey="audio" + /> + ), }; } @@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { id: "text", label: "Text", icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, - content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, + content: ({ trackId }) => ( + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={TEXT_PARAM_KEYS} + sectionKey="text" + /> + ), }; } diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts index bd57d4b0..f6304349 100644 --- a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts +++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts @@ -2,17 +2,18 @@ import { create } from "zustand"; interface PropertiesState {
activeTabPerType: Record<string, string>;
- setActiveTab: (elementType: string, tabId: string) => void;
+ setActiveTab: (args: { elementType: string; tabId: string }) => void;
isTransformScaleLocked: boolean;
- setTransformScaleLocked: (locked: boolean) => void;
+ setTransformScaleLocked: (args: { locked: boolean }) => void;
}
export const usePropertiesStore = create<PropertiesState>()((set) => ({
activeTabPerType: {},
- setActiveTab: (elementType, tabId) =>
+ setActiveTab: ({ elementType, tabId }) =>
set((state) => ({
activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
})),
isTransformScaleLocked: false,
- setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+ setTransformScaleLocked: ({ locked }) =>
+ set({ isTransformScaleLocked: locked }),
}));
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index 3e55e1d6..0977666f 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -39,7 +39,7 @@ export class EditorCore { this.project = new ProjectManager(this); this.media = new MediaManager(this); this.renderer = new RendererManager(this); - this.save = new SaveManager(this); + this.save = new SaveManager({ editor: this }); this.audio = new AudioManager(this); this.selection = new SelectionManager(this); this.clipboard = new ClipboardManager(this); diff --git a/apps/web/src/core/managers/clipboard-manager.ts b/apps/web/src/core/managers/clipboard-manager.ts index 8a2a51c9..283a78ae 100644 --- a/apps/web/src/core/managers/clipboard-manager.ts +++ b/apps/web/src/core/managers/clipboard-manager.ts @@ -6,6 +6,7 @@ import { type CopyContext, type PasteContext, } from "@/clipboard"; +import type { MediaTime } from "@/wasm"; export class ClipboardManager { private entry: ClipboardEntry | null = null; @@ -34,7 +35,7 @@ export class ClipboardManager { return true; } - paste({ time }: { time?: number } = {}): boolean { + paste({ time }: { time?: MediaTime } = {}): boolean { if (!this.entry) { return false; } @@ -64,7 +65,7 @@ export class ClipboardManager { }; } - private getPasteContext({ time }: { time?: number }): PasteContext { + private getPasteContext({ time }: { time?: MediaTime }): PasteContext { return { editor: this.editor, selectedElements: this.editor.selection.getSelectedElements(), diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts index 553d1557..69da6e80 100644 --- a/apps/web/src/core/managers/media-manager.ts +++ b/apps/web/src/core/managers/media-manager.ts @@ -68,9 +68,17 @@ export class MediaManager { const command = uniqueIds.length === 1 - ? new RemoveMediaAssetCommand(projectId, uniqueIds[0]) + ? new RemoveMediaAssetCommand({ + projectId, + assetId: uniqueIds[0], + }) : new BatchCommand( - uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)), + uniqueIds.map((id) => + new RemoveMediaAssetCommand({ + projectId, + assetId: id, + }), + ), ); this.editor.command.execute({ command }); diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index a243dc19..9b9d26bc 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -1,20 +1,26 @@ import type { EditorCore } from "@/core"; -import { TICKS_PER_SECOND } from "@/wasm"; -import { roundToFrame } from "opencut-wasm"; +import { + addMediaTime, + clampMediaTime, + type MediaTime, + mediaTimeFromSeconds, + roundFrameTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class PlaybackManager { private isPlaying = false; - private currentTime = 0; + private currentTime: MediaTime = ZERO_MEDIA_TIME; private volume = 1; private muted = false; private previousVolume = 1; private isScrubbing = false; private listeners = new Set<() => void>(); - private updateListeners = new Set<(time: number) => void>(); - private seekListeners = new Set<(time: number) => void>(); + private updateListeners = new Set<(time: MediaTime) => void>(); + private seekListeners = new Set<(time: MediaTime) => void>(); private playbackTimer: number | null = null; private playbackStartWallTime = 0; - private playbackStartTime = 0; + private playbackStartTime: MediaTime = ZERO_MEDIA_TIME; private timelineScopeBound = false; constructor(private editor: EditorCore) {} @@ -40,7 +46,7 @@ export class PlaybackManager { } if (this.currentTime >= maxTime) { - this.seek({ time: 0 }); + this.seek({ time: ZERO_MEDIA_TIME }); } this.isPlaying = true; @@ -62,7 +68,7 @@ export class PlaybackManager { } } - seek({ time }: { time: number }): void { + seek({ time }: { time: MediaTime }): void { this.currentTime = this.clampTimeToTimeline(time); if (this.isPlaying) { this.playbackStartWallTime = performance.now(); @@ -109,7 +115,7 @@ export class PlaybackManager { return this.isPlaying; } - getCurrentTime(): number { + getCurrentTime(): MediaTime { return this.currentTime; } @@ -135,12 +141,12 @@ export class PlaybackManager { return () => this.listeners.delete(listener); } - onUpdate(listener: (time: number) => void): () => void { + onUpdate(listener: (time: MediaTime) => void): () => void { this.updateListeners.add(listener); return () => this.updateListeners.delete(listener); } - onSeek(listener: (time: number) => void): () => void { + onSeek(listener: (time: MediaTime) => void): () => void { this.seekListeners.add(listener); return () => this.seekListeners.delete(listener); } @@ -165,6 +171,7 @@ export class PlaybackManager { if (timeChanged) { this.notifySeek(this.currentTime); + this.dispatchSeekEvent(this.currentTime); } } @@ -174,6 +181,18 @@ export class PlaybackManager { }); } + private notifyUpdate(time: MediaTime): void { + this.updateListeners.forEach((fn) => { + fn(time); + }); + } + + private notifySeek(time: MediaTime): void { + this.seekListeners.forEach((fn) => { + fn(time); + }); + } + private startTimer(): void { if (this.playbackTimer) { cancelAnimationFrame(this.playbackTimer); @@ -197,40 +216,42 @@ export class PlaybackManager { const fps = this.editor.project.getActive()?.settings.fps; const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000; - const rawTime = - this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND); - const newTime = fps - ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) - : rawTime; + const rawTime = addMediaTime({ + a: this.playbackStartTime, + b: mediaTimeFromSeconds({ seconds: elapsedSeconds }), + }); + const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime; const maxTime = this.editor.timeline.getTotalDuration(); if (newTime >= maxTime) { this.pause(); this.currentTime = maxTime; this.notify(); - this.notifySeek(maxTime); - return; + this.notifySeek(maxTime); + this.dispatchSeekEvent(maxTime); + return; } this.currentTime = newTime; this.notifyUpdate(newTime); + this.dispatchUpdateEvent(newTime); this.playbackTimer = requestAnimationFrame(this.updateTime); }; - private clampTimeToTimeline(time: number): number { + private clampTimeToTimeline(time: MediaTime): MediaTime { const maxTime = this.editor.timeline.getTotalDuration(); - return Math.max(0, Math.min(maxTime, time)); + return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime }); } - private notifySeek(time: number): void { - for (const fn of this.seekListeners) { - fn(time); + private dispatchSeekEvent(time: MediaTime): void { + if (typeof window === "undefined") { + return; } } - private notifyUpdate(time: number): void { - for (const fn of this.updateListeners) { - fn(time); + private dispatchUpdateEvent(time: MediaTime): void { + if (typeof window === "undefined") { + return; } } } diff --git a/apps/web/src/core/managers/save-manager.ts b/apps/web/src/core/managers/save-manager.ts index b3c894d4..8cb2275a 100644 --- a/apps/web/src/core/managers/save-manager.ts +++ b/apps/web/src/core/managers/save-manager.ts @@ -12,13 +12,18 @@ export class SaveManager { private saveTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeHandlers: Array<() => void> = [];
- constructor(
- private editor: EditorCore,
- { debounceMs = 800 }: SaveManagerOptions = {},
- ) {
+ constructor({
+ editor,
+ debounceMs = 800,
+ }: {
+ editor: EditorCore;
+ } & SaveManagerOptions) {
+ this.editor = editor;
this.debounceMs = debounceMs;
}
+ private editor: EditorCore;
+
start(): void {
if (this.unsubscribeHandlers.length > 0) return;
diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index 13f251a1..a4c364ce 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -1,5 +1,5 @@ import type { EditorCore } from "@/core"; -import type { SceneTracks, TScene } from "@/timeline"; +import type { Bookmark, SceneTracks, TScene } from "@/timeline"; import { storageService } from "@/services/storage/service"; import { getMainScene, @@ -21,6 +21,7 @@ import { ToggleBookmarkCommand, UpdateBookmarkCommand, } from "@/commands/scene"; +import type { MediaTime } from "@/wasm"; export class ScenesManager { private active: TScene | null = null; @@ -40,7 +41,7 @@ export class ScenesManager { throw new Error("No active project"); } - const command = new CreateSceneCommand(name, isMain); + const command = new CreateSceneCommand({ name, isMain }); this.editor.command.execute({ command }); return command.getSceneId(); } @@ -76,7 +77,10 @@ export class ScenesManager { throw new Error("No active project"); } - const command = new RenameSceneCommand(sceneId, name); + const command = new RenameSceneCommand({ + sceneId, + newName: name, + }); this.editor.command.execute({ command }); } @@ -106,12 +110,12 @@ export class ScenesManager { this.notify(); } - async toggleBookmark({ time }: { time: number }): Promise<void> { + async toggleBookmark({ time }: { time: MediaTime }): Promise<void> { const command = new ToggleBookmarkCommand(time); this.editor.command.execute({ command }); } - isBookmarked({ time }: { time: number }): boolean { + isBookmarked({ time }: { time: MediaTime }): boolean { const activeScene = this.getActiveScene(); const activeProject = this.editor.project.getActive(); @@ -125,7 +129,7 @@ export class ScenesManager { return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime }); } - async removeBookmark({ time }: { time: number }): Promise<void> { + async removeBookmark({ time }: { time: MediaTime }): Promise<void> { const command = new RemoveBookmarkCommand(time); this.editor.command.execute({ command }); } @@ -134,10 +138,10 @@ export class ScenesManager { time, updates, }: { - time: number; - updates: Partial<{ note: string; color: string; duration: number }>; + time: MediaTime; + updates: Partial<Omit<Bookmark, "time">>; }): Promise<void> { - const command = new UpdateBookmarkCommand(time, updates); + const command = new UpdateBookmarkCommand({ time, updates }); this.editor.command.execute({ command }); } @@ -145,14 +149,14 @@ export class ScenesManager { fromTime, toTime, }: { - fromTime: number; - toTime: number; + fromTime: MediaTime; + toTime: MediaTime; }): Promise<void> { - const command = new MoveBookmarkCommand(fromTime, toTime); + const command = new MoveBookmarkCommand({ fromTime, toTime }); this.editor.command.execute({ command }); } - getBookmarkAtTime({ time }: { time: number }) { + getBookmarkAtTime({ time }: { time: MediaTime }) { const activeScene = this.active; const activeProject = this.editor.project.getActive(); diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index a2bf7451..6fb28625 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -11,23 +11,23 @@ import type { import { calculateTotalDuration } from "@/timeline"; import { TimelineDragSource } from "@/timeline/drag-source"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; +import { lastFrameMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; import { canElementBeHidden, canElementHaveAudio, } from "@/timeline/element-utils"; +import { isElementMuted } from "@/timeline/audio-state"; import type { AnimationPath, AnimationInterpolation, - AnimationValue, - AnimationValueForPath, ScalarCurveKeyframePatch, } from "@/animation/types"; +import type { ParamValue } from "@/params"; import { getElementLocalTime, resolveAnimationPathValueAtTime, } from "@/animation"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; -import { lastFrameTime } from "opencut-wasm"; import { BatchCommand } from "@/commands"; import { AddTrackCommand, @@ -46,8 +46,8 @@ import { RetimeKeyframeCommand, UpdateScalarKeyframeCurveCommand, AddClipEffectCommand, - DeleteCustomMaskPointsCommand, - InsertCustomMaskPointCommand, + DeleteFreeformPathMaskPointsCommand, + InsertFreeformPathMaskPointCommand, RemoveClipEffectCommand, UpdateClipEffectParamsCommand, ToggleClipEffectCommand, @@ -73,7 +73,7 @@ export class TimelineManager { constructor(private editor: EditorCore) {} addTrack({ type, index }: { type: TrackType; index?: number }): string { - const command = new AddTrackCommand(type, index); + const command = new AddTrackCommand({ type, index }); this.editor.command.execute({ command }); return command.getTrackId(); } @@ -97,10 +97,10 @@ export class TimelineManager { pushHistory = true, }: { elementId: string; - trimStart: number; - trimEnd: number; - startTime?: number; - duration?: number; + trimStart: MediaTime; + trimEnd: MediaTime; + startTime?: MediaTime; + duration?: MediaTime; pushHistory?: boolean; }): void { const trackId = this.findTrackIdForElement({ elementId }); @@ -190,7 +190,7 @@ export class TimelineManager { retainSide = "both", }: { elements: { trackId: string; elementId: string }[]; - splitTime: number; + splitTime: MediaTime; retainSide?: "both" | "left" | "right"; }): { trackId: string; elementId: string }[] { const command = new SplitElementsCommand({ @@ -202,20 +202,20 @@ export class TimelineManager { return command.getRightSideElements(); } - getTotalDuration(): number { + getTotalDuration(): MediaTime { const activeScene = this.editor.scenes.getActiveSceneOrNull(); if (!activeScene) { - return 0; + return ZERO_MEDIA_TIME; } return calculateTotalDuration({ tracks: activeScene.tracks }); } - getLastFrameTime(): number { + getLastFrameTime(): MediaTime { const duration = this.getTotalDuration(); const fps = this.editor.project.getActive()?.settings.fps; if (!fps || duration <= 0) return duration; - return lastFrameTime({ duration, rate: fps }) ?? duration; + return lastFrameMediaTime({ duration, fps }); } getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { @@ -349,7 +349,7 @@ export class TimelineManager { this.editor.command.execute({ command }); } - deleteCustomMaskPoints({ + deleteFreeformPathMaskPoints({ trackId, elementId, maskId, @@ -363,7 +363,7 @@ export class TimelineManager { if (pointIds.length === 0) { return; } - const command = new DeleteCustomMaskPointsCommand({ + const command = new DeleteFreeformPathMaskPointsCommand({ trackId, elementId, maskId, @@ -372,7 +372,7 @@ export class TimelineManager { this.editor.command.execute({ command }); } - insertCustomMaskPoint({ + insertFreeformPathMaskPoint({ trackId, elementId, maskId, @@ -387,7 +387,7 @@ export class TimelineManager { canvasPoint: { x: number; y: number }; bounds: ElementBounds; }): void { - const command = new InsertCustomMaskPointCommand({ + const command = new InsertFreeformPathMaskPointCommand({ trackId, elementId, maskId, @@ -485,8 +485,8 @@ export class TimelineManager { trackId: string; elementId: string; propertyPath: AnimationPath; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; }>; @@ -537,7 +537,7 @@ export class TimelineManager { // Pre-sample values at playhead for each (element, property) pair. // This preserves "what you see is what you get" when all keyframes are deleted. const playheadTime = this.editor.playback.getCurrentTime(); - const valueAtPlayheadMap = new Map<string, AnimationValue | null>(); + const valueAtPlayheadMap = new Map<string, ParamValue | null>(); for (const { trackId, elementId, propertyPath } of keyframes) { const key = `${elementId}:${propertyPath}`; @@ -558,9 +558,7 @@ export class TimelineManager { }); const target = resolveAnimationTarget({ element, path: propertyPath }); - const baseValue = - (target?.getBaseValue() as AnimationValueForPath<AnimationPath> | null) ?? - null; + const baseValue = target?.getBaseValue() ?? null; if (baseValue === null) { valueAtPlayheadMap.set(key, null); continue; @@ -602,7 +600,7 @@ export class TimelineManager { elementId: string; propertyPath: AnimationPath; keyframeId: string; - time: number; + time: MediaTime; }): void { const command = new RetimeKeyframeCommand({ trackId, @@ -660,7 +658,7 @@ export class TimelineManager { elementId: string; effectId: string; paramKey: string; - time: number; + time: MediaTime; value: number; interpolation?: "linear" | "hold"; keyframeId?: string; @@ -709,9 +707,9 @@ export class TimelineManager { updates, }: { updates: readonly { - readonly trackId: string; - readonly elementId: string; - readonly updates: Partial<TimelineElement>; + trackId: string; + elementId: string; + updates: Partial<TimelineElement>; }[]; }): void { let changedOverlayCount = 0; @@ -751,7 +749,10 @@ export class TimelineManager { } const afterTracks = this.previewTracks ?? this.applyPreviewOverlay(committedTracks); - const command = new TracksSnapshotCommand(committedTracks, afterTracks); + const command = new TracksSnapshotCommand({ + before: committedTracks, + after: afterTracks, + }); this.editor.command.push({ command }); this.previewOverlay.clear(); this.previewTracks = null; @@ -840,7 +841,7 @@ export class TimelineManager { }): void { const shouldMute = elements.some(({ trackId, elementId }) => { const element = this.getElementByRef({ trackId, elementId }); - return element && canElementHaveAudio(element) && !element.muted; + return element && canElementHaveAudio(element) && !isElementMuted({ element }); }); const nextUpdates = elements.flatMap(({ trackId, elementId }) => { @@ -853,7 +854,7 @@ export class TimelineManager { { trackId, elementId, - patch: { muted: shouldMute }, + patch: { params: { muted: shouldMute } }, }, ]; }); diff --git a/apps/web/src/editor/panel-store.ts b/apps/web/src/editor/panel-store.ts index 2cb768a1..5a2bd0f4 100644 --- a/apps/web/src/editor/panel-store.ts +++ b/apps/web/src/editor/panel-store.ts @@ -14,7 +14,7 @@ export type PanelId = keyof PanelSizes; interface PanelState {
panels: PanelSizes;
- setPanel: (panel: PanelId, size: number) => void;
+ setPanel: (args: { panel: PanelId; size: number }) => void;
setPanels: (sizes: Partial<PanelSizes>) => void;
resetPanels: () => void;
}
@@ -23,7 +23,7 @@ export const usePanelStore = create<PanelState>()( persist(
(set) => ({
...PANEL_CONFIG,
- setPanel: (panel, size) =>
+ setPanel: ({ panel, size }) =>
set((state) => ({
panels: {
...state.panels,
diff --git a/apps/web/src/editor/use-editor.ts b/apps/web/src/editor/use-editor.ts index ce48e036..6380d470 100644 --- a/apps/web/src/editor/use-editor.ts +++ b/apps/web/src/editor/use-editor.ts @@ -1,7 +1,15 @@ import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; import { EditorCore } from "@/core"; -function isShallowEqual(a: unknown, b: unknown): boolean { +const SNAPSHOT_UNSET = Symbol("snapshotUnset"); + +function isShallowEqual({ + a, + b, +}: { + a: unknown; + b: unknown; +}): boolean { if (Object.is(a, b)) return true; if (!Array.isArray(a) || !Array.isArray(b)) return false; if (a.length !== b.length) return false; @@ -16,10 +24,7 @@ export function useEditor<T>( selector?: (editor: EditorCore) => T, ): EditorCore | T { const editor = useMemo(() => EditorCore.getInstance(), []); - const selectorRef = useRef(selector); - selectorRef.current = selector; - - const snapshotCacheRef = useRef<unknown>(undefined); + const snapshotCacheRef = useRef<T | typeof SNAPSHOT_UNSET>(SNAPSHOT_UNSET); const subscribeAll = useCallback( (onChange: () => void) => { @@ -43,18 +48,29 @@ export function useEditor<T>( [editor], ); - const getSnapshot = useCallback(() => { - const next = selectorRef.current ? selectorRef.current(editor) : editor; - if (isShallowEqual(snapshotCacheRef.current, next)) { + const getSnapshot = useCallback((): EditorCore | T => { + if (!selector) { + return editor; + } + + const next = selector(editor); + if ( + snapshotCacheRef.current !== SNAPSHOT_UNSET && + isShallowEqual({ + a: snapshotCacheRef.current, + b: next, + }) + ) { return snapshotCacheRef.current; } + snapshotCacheRef.current = next; return next; - }, [editor]); + }, [editor, selector]); return useSyncExternalStore( selector ? subscribeAll : subscribeNone, getSnapshot, getSnapshot, - ) as EditorCore | T; + ); } diff --git a/apps/web/src/effects/definitions/index.ts b/apps/web/src/effects/definitions/index.ts index edc8c5e2..511df042 100644 --- a/apps/web/src/effects/definitions/index.ts +++ b/apps/web/src/effects/definitions/index.ts @@ -8,6 +8,9 @@ export function registerDefaultEffects(): void { if (effectsRegistry.has(definition.type)) { continue; } - effectsRegistry.register(definition.type, definition); + effectsRegistry.register({ + key: definition.type, + definition, + }); } } diff --git a/apps/web/src/fps/utils.ts b/apps/web/src/fps/utils.ts index e30e263a..c2a57fbb 100644 --- a/apps/web/src/fps/utils.ts +++ b/apps/web/src/fps/utils.ts @@ -22,7 +22,13 @@ export function frameRateToFloat(rate: FrameRate): number { return rate.numerator / rate.denominator; } -export function frameRatesEqual(a: FrameRate, b: FrameRate): boolean { +export function frameRatesEqual({ + a, + b, +}: { + a: FrameRate; + b: FrameRate; +}): boolean { return a.numerator === b.numerator && a.denominator === b.denominator; } @@ -38,14 +44,17 @@ export function floatToFrameRate(fps: number): FrameRate { const ARBITRARY_DENOMINATOR = 1_000_000; const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR); - const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR); + const divisor = gcd({ + left: scaledNumerator, + right: ARBITRARY_DENOMINATOR, + }); return { numerator: scaledNumerator / divisor, denominator: ARBITRARY_DENOMINATOR / divisor, }; } -function gcd(left: number, right: number): number { +function gcd({ left, right }: { left: number; right: number }): number { let a = Math.abs(left); let b = Math.abs(right); while (b !== 0) { diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx index e0ae0236..31c4a86d 100644 --- a/apps/web/src/graphics/components/graphic-tab.tsx +++ b/apps/web/src/graphics/components/graphic-tab.tsx @@ -8,11 +8,7 @@ import { } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; import type { ParamDefinition, ParamValues } from "@/params"; import type { GraphicElement } from "@/timeline"; -import { - graphicsRegistry, - registerDefaultGraphics, - resolveGraphicElementParamsAtTime, -} from "@/graphics"; +import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics"; import { useElementPreview } from "@/timeline/hooks/use-element-preview"; import { useEditor } from "@/editor/use-editor"; import { @@ -27,6 +23,7 @@ import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; import { cn } from "@/utils/ui"; +import type { MediaTime } from "@/wasm"; registerDefaultGraphics(); @@ -200,10 +197,11 @@ function AnimatedGraphicParamField({ isPlayheadWithinElementRange, resolvedParams, }: { + key?: string; param: ParamDefinition; trackId: string; element: GraphicElement; - localTime: number; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedParams: ParamValues; }) { diff --git a/apps/web/src/graphics/definitions/index.ts b/apps/web/src/graphics/definitions/index.ts index 6fd9e446..846a9983 100644 --- a/apps/web/src/graphics/definitions/index.ts +++ b/apps/web/src/graphics/definitions/index.ts @@ -16,7 +16,10 @@ export function registerDefaultGraphics(): void { if (graphicsRegistry.has(definition.id)) {
continue;
}
- graphicsRegistry.register(definition.id, definition);
+ graphicsRegistry.register({
+ key: definition.id,
+ definition,
+ });
}
}
diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts index 7f5ac08b..7b8d905e 100644 --- a/apps/web/src/graphics/index.ts +++ b/apps/web/src/graphics/index.ts @@ -60,10 +60,13 @@ export function buildDefaultGraphicInstance({ }; } -export function resolveGraphicParams( - definition: GraphicDefinition, - params?: ParamValues, -): ParamValues { +export function resolveGraphicParams({ + definition, + params, +}: { + definition: GraphicDefinition; + params?: ParamValues; +}): ParamValues { return { ...buildDefaultParamValues(definition.params), ...(params ?? {}), @@ -85,7 +88,10 @@ export function resolveGraphicElementParamsAtTime({ definitionId: element.definitionId, }); return resolveGraphicParamsAtTime({ - params: resolveGraphicParams(definition, element.params), + params: resolveGraphicParams({ + definition, + params: element.params, + }), definitions: definition.params, animations: element.animations, localTime, @@ -102,7 +108,7 @@ export function buildGraphicPreviewUrl({ size?: number; }): string { const definition = getGraphicDefinition({ definitionId }); - const resolvedParams = resolveGraphicParams(definition, params); + const resolvedParams = resolveGraphicParams({ definition, params }); const cacheKey = JSON.stringify({ definitionId, resolvedParams, size }); const cachedUrl = graphicPreviewUrlCache.get(cacheKey); if (cachedUrl) { diff --git a/apps/web/src/hooks/use-committed-ref.ts b/apps/web/src/hooks/use-committed-ref.ts new file mode 100644 index 00000000..ff064985 --- /dev/null +++ b/apps/web/src/hooks/use-committed-ref.ts @@ -0,0 +1,11 @@ +import { useLayoutEffect, useRef } from "react"; + +export function useCommittedRef<T>(value: T) { + const ref = useRef(value); + + useLayoutEffect(() => { + ref.current = value; + }, [value]); + + return ref; +} diff --git a/apps/web/src/hooks/use-focus-lock.ts b/apps/web/src/hooks/use-focus-lock.ts index 236d2623..86092aec 100644 --- a/apps/web/src/hooks/use-focus-lock.ts +++ b/apps/web/src/hooks/use-focus-lock.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
@@ -37,8 +38,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({ allowSelector?: string;
}) {
const containerRef = useRef<T>(null);
- const onDismissRef = useRef(onDismiss);
- onDismissRef.current = onDismiss;
+ const onDismissRef = useCommittedRef(onDismiss);
useEffect(() => {
if (!isActive) return;
@@ -53,10 +53,13 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({ const handleOutsidePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
- if (container.contains(event.target as Node)) return;
+ const target = event.target;
+ if (target instanceof Node && container.contains(target)) return;
- const target = event.target as Element | null;
- const isAllowedTarget = allowSelector && target?.closest(allowSelector);
+ const isAllowedTarget =
+ allowSelector &&
+ target instanceof Element &&
+ target.closest(allowSelector);
if (isAllowedTarget) return;
onDismissRef.current();
@@ -73,7 +76,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({ container.removeAttribute(DATA_ATTR);
focusLockStyle.remove();
};
- }, [isActive, cursor, allowSelector]);
+ }, [isActive, cursor, allowSelector, onDismissRef]);
return { containerRef };
}
diff --git a/apps/web/src/masks/__tests__/snap.test.ts b/apps/web/src/masks/__tests__/snap.test.ts index 2f33615f..a5d0ceb3 100644 --- a/apps/web/src/masks/__tests__/snap.test.ts +++ b/apps/web/src/masks/__tests__/snap.test.ts @@ -1,22 +1,22 @@ import { describe, expect, test } from "bun:test"; import { - findClosestPointOnCustomMaskSegment, - getCustomMaskClosedStateAfterPointRemoval, - insertPointIntoCustomMaskSegment, - removeCustomMaskPoints, -} from "@/masks/custom-path"; + findClosestPointOnFreeformSegment, + getFreeformPathClosedStateAfterPointRemoval, + insertPointIntoFreeformSegment, + removeFreeformPathPoints, +} from "@/masks/freeform/path"; import { - appendPointToCustomMask, - customMaskDefinition, - insertPointOnCustomMaskSegment, -} from "@/masks/definitions/custom"; -import { getSplitMaskStrokeSegment } from "@/masks/definitions/split"; -import { textMaskDefinition } from "@/masks/definitions/text"; + appendPointToFreeformPathMask, + freeformMaskDefinition, + insertPointOnFreeformSegment, +} from "@/masks/freeform/definition"; +import { getSplitMaskStrokeSegment } from "@/masks/builtin/definitions/split"; +import { textMaskDefinition } from "@/masks/builtin/definitions/text"; import { getMaskSnapGeometry } from "@/masks/geometry"; -import { snapMaskInteraction } from "@/masks/snap"; +import { snapBoxMaskInteraction, snapSplitMaskInteraction } from "@/masks/snap"; import type { ElementBounds } from "@/preview/element-bounds"; import type { - CustomMaskParams, + FreeformPathMaskParams, RectangleMaskParams, SplitMaskParams, TextMaskParams, @@ -100,9 +100,9 @@ function buildTextMaskParams( }; } -function buildCustomMaskParams( - overrides: Partial<CustomMaskParams> = {}, -): CustomMaskParams { +function buildFreeformPathMaskParams( + overrides: Partial<FreeformPathMaskParams> = {}, +): FreeformPathMaskParams { return { feather: 0, inverted: false, @@ -150,10 +150,10 @@ function buildCustomMaskParams( function sortSegment( segment: [{ x: number; y: number }, { x: number; y: number }], ): [{ x: number; y: number }, { x: number; y: number }] { - return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ - { x: number; y: number }, - { x: number; y: number }, - ]; + const [first, second] = segment; + return first.x < second.x || (first.x === second.x && first.y <= second.y) + ? [first, second] + : [second, first]; } describe("mask geometry", () => { @@ -230,8 +230,8 @@ describe("mask geometry", () => { describe("mask snapping", () => { test("snaps split mask movement using the shared position pipeline", () => { - const result = snapMaskInteraction({ - handleId: "position", + const result = snapSplitMaskInteraction({ + handleId: { kind: "position" }, startParams: buildSplitParams({ centerX: 0.03, centerY: -0.04, @@ -254,8 +254,8 @@ describe("mask snapping", () => { }); test("snaps box mask movement against element center and edges", () => { - const result = snapMaskInteraction({ - handleId: "position", + const result = snapBoxMaskInteraction({ + handleId: { kind: "position" }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ centerX: 0.29, @@ -275,8 +275,8 @@ describe("mask snapping", () => { }); test("snaps mask rotation through the shared rotation path", () => { - const result = snapMaskInteraction({ - handleId: "rotation", + const result = snapBoxMaskInteraction({ + handleId: { kind: "rotation" }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ rotation: 88, @@ -291,8 +291,8 @@ describe("mask snapping", () => { }); test("snaps edge resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "right", + const result = snapBoxMaskInteraction({ + handleId: { kind: "edge", side: "right" }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ width: 0.98, @@ -307,8 +307,8 @@ describe("mask snapping", () => { }); test("snaps corner resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "bottom-right", + const result = snapBoxMaskInteraction({ + handleId: { kind: "corner", corner: { x: "right", y: "bottom" } }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ width: 0.99, @@ -324,13 +324,49 @@ describe("mask snapping", () => { expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); }); + test("snaps vertical edge resize for box masks", () => { + // bounds.height=100 → localCanvasSize.height/2=50 is the bottom snap target. + // height=0.2 → baseHeight=20 → aabbHalfH=10. + // At height=0.98 → proposedScaleY=4.9 → bottomEdge=0+10*4.9=49. + // |49-50|=1 < threshold(8) → snaps to height=0.2*5=1.0; line at position 50. + const result = snapBoxMaskInteraction({ + handleId: { kind: "edge", side: "bottom" }, + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ height: 0.98 }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.height).toBe(1); + expect(result.activeLines).toEqual([{ type: "horizontal", position: 50 }]); + }); + + test("snaps uniform scale handle for box masks", () => { + // bounds.width=200 → localCanvasSize.width/2=100 is the right snap target. + // width=0.4, scale=1 → aabbHalfW = (0.4*200)/2 * 1 = 40. + // At scale=2.48 → rightEdge=0+40*2.48=99.2; |99.2-100|=0.8 < threshold(8) + // → snaps to scale=1*(100/40)=2.5; line at position 100. + const result = snapBoxMaskInteraction({ + handleId: { kind: "scale" }, + startParams: buildRectangleParams({ scale: 1 }), + proposedParams: buildRectangleParams({ scale: 2.48 }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.scale).toBe(2.5); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); + test("snaps text mask movement using intrinsic text bounds", () => { const params = buildTextMaskParams({ centerX: 0.03, centerY: -0.04, }); const result = textMaskDefinition.interaction.snap?.({ - handleId: "position", + handleId: { kind: "position" }, startParams: params, proposedParams: params, bounds, @@ -347,12 +383,12 @@ describe("mask snapping", () => { }); test("snaps custom mask movement using path geometry bounds", () => { - const params = buildCustomMaskParams({ + const params = buildFreeformPathMaskParams({ centerX: 0.03, centerY: -0.04, }); - const result = customMaskDefinition.interaction.snap?.({ - handleId: "position", + const result = freeformMaskDefinition.interaction.snap?.({ + handleId: { kind: "position" }, startParams: params, proposedParams: params, bounds, @@ -381,11 +417,11 @@ describe("mask snapping", () => { describe("custom mask creation", () => { test("anchors the first point at the click position", () => { - const params = buildCustomMaskParams({ + const params = buildFreeformPathMaskParams({ path: [], closed: false, }); - const next = appendPointToCustomMask({ + const next = appendPointToFreeformPathMask({ params, canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 }, bounds, @@ -401,8 +437,8 @@ describe("custom mask creation", () => { describe("custom mask point deletion", () => { test("removes the selected points by id", () => { - const points = buildCustomMaskParams().path; - const nextPoints = removeCustomMaskPoints({ + const points = buildFreeformPathMaskParams().path; + const nextPoints = removeFreeformPathPoints({ points, pointIds: ["b"], }); @@ -411,14 +447,14 @@ describe("custom mask point deletion", () => { }); test("reopens a closed path once fewer than three points remain", () => { - const points = buildCustomMaskParams().path; - const nextPoints = removeCustomMaskPoints({ + const points = buildFreeformPathMaskParams().path; + const nextPoints = removeFreeformPathPoints({ points, pointIds: ["c"], }); expect( - getCustomMaskClosedStateAfterPointRemoval({ + getFreeformPathClosedStateAfterPointRemoval({ wasClosed: true, remainingPointCount: nextPoints.length, }), @@ -428,9 +464,9 @@ describe("custom mask point deletion", () => { describe("custom mask point insertion", () => { test("finds the closest point on the clicked segment", () => { - const params = buildCustomMaskParams(); + const params = buildFreeformPathMaskParams(); const points = params.path; - const closestPoint = findClosestPointOnCustomMaskSegment({ + const closestPoint = findClosestPointOnFreeformSegment({ points, segmentIndex: 0, canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, @@ -449,8 +485,8 @@ describe("custom mask point insertion", () => { }); test("splits a segment into two segments at the insertion point", () => { - const points = buildCustomMaskParams().path; - const nextPoints = insertPointIntoCustomMaskSegment({ + const points = buildFreeformPathMaskParams().path; + const nextPoints = insertPointIntoFreeformSegment({ points, segmentIndex: 0, pointId: "new", @@ -471,8 +507,8 @@ describe("custom mask point insertion", () => { }); test("builds updated custom mask params for a clicked segment", () => { - const result = insertPointOnCustomMaskSegment({ - params: buildCustomMaskParams(), + const result = insertPointOnFreeformSegment({ + params: buildFreeformPathMaskParams(), segmentIndex: 0, canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, bounds, diff --git a/apps/web/src/masks/definitions/box-like.ts b/apps/web/src/masks/builtin/box-like.ts index 7db51df7..37706755 100644 --- a/apps/web/src/masks/definitions/box-like.ts +++ b/apps/web/src/masks/builtin/box-like.ts @@ -16,7 +16,7 @@ import { getBoxMaskHandlePositions, getBoxMaskOverlays, } from "@/masks/handle-positions"; -import { snapMaskInteraction } from "@/masks/snap"; +import { snapBoxMaskInteraction } from "@/masks/snap"; const PERCENTAGE_DISPLAY: Pick< NumberParamDefinition, @@ -198,7 +198,7 @@ export function buildBoxMaskInteraction({ }; }, snap(args) { - return snapMaskInteraction(args); + return snapBoxMaskInteraction(args); }, }; } @@ -210,20 +210,20 @@ export function computeBoxMaskParamUpdate({ deltaY, bounds, }: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> { - if (handleId === "position") { + if (handleId.kind === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, centerY: startParams.centerY + deltaY / bounds.height, }; } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; const newRotation = (startParams.rotation + currentAngle) % 360; return { rotation: newRotation < 0 ? newRotation + 360 : newRotation }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -237,8 +237,11 @@ export function computeBoxMaskParamUpdate({ const halfWidth = startParams.width * bounds.width; const halfHeight = startParams.height * bounds.height; - if (handleId === "right" || handleId === "left") { - const sign = handleId === "right" ? 1 : -1; + if ( + handleId.kind === "edge" && + (handleId.side === "right" || handleId.side === "left") + ) { + const sign = handleId.side === "right" ? 1 : -1; return { width: Math.max( MIN_MASK_DIMENSION, @@ -247,8 +250,11 @@ export function computeBoxMaskParamUpdate({ }; } - if (handleId === "bottom" || handleId === "top") { - const sign = handleId === "bottom" ? 1 : -1; + if ( + handleId.kind === "edge" && + (handleId.side === "bottom" || handleId.side === "top") + ) { + const sign = handleId.side === "bottom" ? 1 : -1; return { height: Math.max( MIN_MASK_DIMENSION, @@ -257,14 +263,9 @@ export function computeBoxMaskParamUpdate({ }; } - if ( - handleId === "top-left" || - handleId === "top-right" || - handleId === "bottom-left" || - handleId === "bottom-right" - ) { - const signX = handleId.includes("right") ? 1 : -1; - const signY = handleId.includes("bottom") ? 1 : -1; + if (handleId.kind === "corner") { + const signX = handleId.corner.x === "right" ? 1 : -1; + const signY = handleId.corner.y === "bottom" ? 1 : -1; const distance = Math.sqrt( (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2, ); @@ -276,7 +277,7 @@ export function computeBoxMaskParamUpdate({ }; } - if (handleId === "scale") { + if (handleId.kind === "scale") { const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2); const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1; diff --git a/apps/web/src/masks/definitions/cinematic-bars.ts b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts index 36b31328..0e23fd58 100644 --- a/apps/web/src/masks/definitions/cinematic-bars.ts +++ b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts @@ -10,7 +10,7 @@ import { getDefaultBaseMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function getDefaultCinematicBarsMaskParams({ elementSize, @@ -71,7 +71,7 @@ function buildBandPath({ return path; } -export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = { type: "cinematic-bars", name: "Cinematic Bars", features: { @@ -94,42 +94,51 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const centerX = width / 2 + params.centerX * width; - const centerY = height / 2 + params.centerY * height; - const maskWidth = Math.max(params.width * width, width); - const maskHeight = Math.max(params.height, 0.01) * height; - const rotationRad = (params.rotation * Math.PI) / 180; + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const centerX = width / 2 + params.centerX * width; + const centerY = height / 2 + params.centerY * height; + const maskWidth = Math.max(params.width * width, width); + const maskHeight = Math.max(params.height, 0.01) * height; + const rotationRad = (params.rotation * Math.PI) / 180; - return buildBandPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + return buildBandPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const centerX = width / 2 + params.centerX * width; - const centerY = height / 2 + params.centerY * height; - const rotationRad = (params.rotation * Math.PI) / 180; - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const centerX = width / 2 + params.centerX * width; + const centerY = height / 2 + params.centerY * height; + const rotationRad = (params.rotation * Math.PI) / 180; + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); - return buildBandPath({ - centerX, - centerY, - halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1), - halfHeight: Math.max( - (Math.max(params.height, 0.01) * height) / 2 + offset, - 1, - ), - rotationRad, - }); + return buildBandPath({ + centerX, + centerY, + halfWidth: Math.max( + Math.max(params.width * width, width) / 2 + offset, + 1, + ), + halfHeight: Math.max( + (Math.max(params.height, 0.01) * height) / 2 + offset, + 1, + ), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/diamond.ts b/apps/web/src/masks/builtin/definitions/diamond.ts index 037aee70..1effb276 100644 --- a/apps/web/src/masks/definitions/diamond.ts +++ b/apps/web/src/masks/builtin/definitions/diamond.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function buildDiamondPath({ centerX, @@ -45,7 +45,7 @@ function buildDiamondPath({ return path; } -export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const diamondMaskDefinition: MaskDefinition<"diamond"> = { type: "diamond", name: "Diamond", features: { @@ -68,33 +68,39 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildDiamondPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildDiamondPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildDiamondPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildDiamondPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/builtin/definitions/ellipse.ts b/apps/web/src/masks/builtin/definitions/ellipse.ts new file mode 100644 index 00000000..688a2fa6 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/ellipse.ts @@ -0,0 +1,81 @@ +import type { MaskDefinition } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, +} from "../box-like"; + +export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = { + type: "ellipse", + name: "Ellipse", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + buildOverlayPath({ width, height }) { + const rx = Math.max((width - 1) / 2, 0); + const ry = Math.max((height - 1) / 2, 0); + const cx = width / 2; + const cy = height / 2; + return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`; + }, + }), + buildDefault(context) { + return { + type: "ellipse", + params: getDefaultSquareMaskParams(context), + }; + }, + computeParamUpdate: computeBoxMaskParamUpdate, + renderer: { + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const path = new Path2D(); + path.ellipse( + centerX, + centerY, + maskWidth / 2, + maskHeight / 2, + rotationRad, + 0, + Math.PI * 2, + ); + return path; + }, + }, + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + const path = new Path2D(); + path.ellipse( + centerX, + centerY, + Math.max(1, maskWidth / 2 + offset), + Math.max(1, maskHeight / 2 + offset), + rotationRad, + 0, + Math.PI * 2, + ); + return path; + }, + }, + }, +}; diff --git a/apps/web/src/masks/definitions/heart.ts b/apps/web/src/masks/builtin/definitions/heart.ts index d15c09e6..d1af5eb2 100644 --- a/apps/web/src/masks/definitions/heart.ts +++ b/apps/web/src/masks/builtin/definitions/heart.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function buildHeartPath({ centerX, @@ -78,7 +78,7 @@ function buildHeartPath({ return path; } -export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const heartMaskDefinition: MaskDefinition<"heart"> = { type: "heart", name: "Heart", features: { @@ -110,33 +110,39 @@ export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildHeartPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildHeartPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildHeartPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildHeartPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/index.ts b/apps/web/src/masks/builtin/definitions/index.ts index 9cee1020..5deb4e3f 100644 --- a/apps/web/src/masks/definitions/index.ts +++ b/apps/web/src/masks/builtin/definitions/index.ts @@ -1,7 +1,9 @@ -import type { BaseMaskParams, MaskDefinition } from "@/masks/types"; -import { masksRegistry, type MaskIconProps } from "../registry"; +import { + masksRegistry, + type MaskDefinitionForRegistration, + type MaskIconProps, +} from "../../registry"; import { cinematicBarsMaskDefinition } from "./cinematic-bars"; -import { customMaskDefinition } from "./custom"; import { diamondMaskDefinition } from "./diamond"; import { ellipseMaskDefinition } from "./ellipse"; import { heartMaskDefinition } from "./heart"; @@ -9,6 +11,7 @@ import { rectangleMaskDefinition } from "./rectangle"; import { splitMaskDefinition } from "./split"; import { starMaskDefinition } from "./star"; import { textMaskDefinition } from "./text"; +import { freeformMaskDefinition } from "../../freeform/definition"; import { MinusSignIcon, PanelRightDashedIcon, @@ -18,13 +21,14 @@ import { DiamondIcon, StarsIcon, TextFontIcon, + PenToolAddIcon, } from "@hugeicons/core-free-icons"; -function registerDefaultMask<TParams extends BaseMaskParams>({ +function registerDefaultMask({ definition, icon, }: { - definition: MaskDefinition<TParams>; + definition: MaskDefinitionForRegistration; icon: MaskIconProps; }) { if (masksRegistry.has(definition.type)) { @@ -68,7 +72,7 @@ export function registerDefaultMasks(): void { icon: { icon: TextFontIcon }, }); registerDefaultMask({ - definition: customMaskDefinition, - icon: { icon: SquareIcon }, + definition: freeformMaskDefinition, + icon: { icon: PenToolAddIcon }, }); } diff --git a/apps/web/src/masks/definitions/rectangle.ts b/apps/web/src/masks/builtin/definitions/rectangle.ts index 7bf1a7fd..4dd38154 100644 --- a/apps/web/src/masks/definitions/rectangle.ts +++ b/apps/web/src/masks/builtin/definitions/rectangle.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function buildRectanglePath({ centerX, @@ -45,7 +45,7 @@ function buildRectanglePath({ return path; } -export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = { type: "rectangle", name: "Rectangle", features: { @@ -65,33 +65,39 @@ export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildRectanglePath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildRectanglePath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildRectanglePath({ - centerX, - centerY, - halfWidth: Math.max(1, maskWidth / 2 + offset), - halfHeight: Math.max(1, maskHeight / 2 + offset), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildRectanglePath({ + centerX, + centerY, + halfWidth: Math.max(1, maskWidth / 2 + offset), + halfHeight: Math.max(1, maskHeight / 2 + offset), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/split.ts b/apps/web/src/masks/builtin/definitions/split.ts index d50a2571..13dd1c09 100644 --- a/apps/web/src/masks/definitions/split.ts +++ b/apps/web/src/masks/builtin/definitions/split.ts @@ -1,15 +1,15 @@ -import { computeFeatherUpdate } from "../param-update"; +import { computeFeatherUpdate } from "@/masks/param-update"; import type { MaskDefinition, MaskParamUpdateArgs, SplitMaskParams, } from "@/masks/types"; -import { halfPlaneSign, lineEdgeIntersection } from "../utils"; +import { halfPlaneSign, lineEdgeIntersection } from "@/masks/utils"; import { getLineMaskHandlePositions, getLineMaskOverlay, } from "@/masks/handle-positions"; -import { snapMaskInteraction } from "@/masks/snap"; +import { snapSplitMaskInteraction } from "@/masks/snap"; // cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped // to exactly 0 to prevent opposite-sign float noise on canvas corners that lie @@ -53,10 +53,13 @@ function splitLineGeometry({ return { normalX, normalY, lineX, lineY }; } -function pointsEqual( - a: { x: number; y: number }, - b: { x: number; y: number }, -): boolean { +function pointsEqual({ + a, + b, +}: { + a: { x: number; y: number }; + b: { x: number; y: number }; +}): boolean { return ( Math.abs(a.x - b.x) <= INTERSECTION_EPSILON && Math.abs(a.y - b.y) <= INTERSECTION_EPSILON @@ -68,11 +71,11 @@ export function getSplitMaskStrokeSegment({ width, height, }: { - resolvedParams: unknown; + resolvedParams: SplitMaskParams; width: number; height: number; }): [{ x: number; y: number }, { x: number; y: number }] | null { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; + const { centerX, centerY, rotation } = resolvedParams; const { normalX, normalY, lineX, lineY } = splitLineGeometry({ centerX, centerY, @@ -101,7 +104,7 @@ export function getSplitMaskStrokeSegment({ y2, }); - if (!hit || intersections.some((point) => pointsEqual(point, hit))) { + if (!hit || intersections.some((point) => pointsEqual({ a: point, b: hit }))) { continue; } @@ -125,7 +128,7 @@ function computeSplitMaskParamUpdate({ bounds, canvasSize, }: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> { - if (handleId === "position") { + if (handleId.kind === "position") { const rawX = startParams.centerX + deltaX / bounds.width; const rawY = startParams.centerY + deltaY / bounds.height; @@ -140,7 +143,7 @@ function computeSplitMaskParamUpdate({ }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -151,7 +154,7 @@ function computeSplitMaskParamUpdate({ }); } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotY = bounds.cy + startParams.centerY * bounds.height; const startAngle = @@ -177,7 +180,7 @@ function computeSplitMaskParamUpdate({ return {}; } -export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { +export const splitMaskDefinition: MaskDefinition<"split"> = { type: "split", name: "Split", features: { @@ -213,7 +216,7 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { }; }, snap(args) { - return snapMaskInteraction(args); + return snapSplitMaskInteraction(args); }, }, buildDefault() { @@ -264,119 +267,131 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { }, ], renderer: { - renderMaskHandlesFeather: true, - renderMask({ resolvedParams, ctx, width, height, feather }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); + body: { + kind: "drawWithFeather", + drawWithFeather({ resolvedParams, ctx, width, height, feather }) { + const { centerX, centerY, rotation } = resolvedParams; + const { normalX, normalY, lineX, lineY } = splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, + }); - // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. - const featherHalf = feather / 2; - const gradient = ctx.createLinearGradient( - lineX - normalX * featherHalf, - lineY - normalY * featherHalf, - lineX + normalX * featherHalf, - lineY + normalY * featherHalf, - ); - gradient.addColorStop(0, "rgba(255,255,255,0)"); - gradient.addColorStop(1, "white"); + // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. + const featherHalf = feather / 2; + const gradient = ctx.createLinearGradient( + lineX - normalX * featherHalf, + lineY - normalY * featherHalf, + lineX + normalX * featherHalf, + lineY + normalY * featherHalf, + ); + gradient.addColorStop(0, "rgba(255,255,255,0)"); + gradient.addColorStop(1, "white"); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); - }, + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + }, - buildPath({ resolvedParams, width, height }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); + opaqueFastPath: { + buildPath({ resolvedParams, width, height }) { + const { centerX, centerY, rotation } = resolvedParams; + const { normalX, normalY, lineX, lineY } = splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, + }); - const edges: [number, number, number, number][] = [ - [0, 0, width, 0], - [width, 0, width, height], - [width, height, 0, height], - [0, height, 0, 0], - ]; + const edges: [number, number, number, number][] = [ + [0, 0, width, 0], + [width, 0, width, height], + [width, height, 0, height], + [0, height, 0, 0], + ]; - const isInsideHalfPlane = (x: number, y: number) => - halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; + const isInsideHalfPlane = ({ + x, + y, + }: { + x: number; + y: number; + }) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; - const vertices: [number, number][] = []; - for (const [x1, y1, x2, y2] of edges) { - const isVertex1Inside = isInsideHalfPlane(x1, y1); - const isVertex2Inside = isInsideHalfPlane(x2, y2); + const vertices: [number, number][] = []; + for (const [x1, y1, x2, y2] of edges) { + const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 }); + const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 }); - if (isVertex1Inside && isVertex2Inside) { - vertices.push([x2, y2]); - } else if (isVertex1Inside && !isVertex2Inside) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - if (hit) vertices.push([hit.x, hit.y]); - } else if (!isVertex1Inside && isVertex2Inside) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - if (hit) { - vertices.push([hit.x, hit.y]); - vertices.push([x2, y2]); + if (isVertex1Inside && isVertex2Inside) { + vertices.push([x2, y2]); + } else if (isVertex1Inside && !isVertex2Inside) { + const hit = lineEdgeIntersection({ + lineX, + lineY, + normalX, + normalY, + x1, + y1, + x2, + y2, + }); + if (hit) vertices.push([hit.x, hit.y]); + } else if (!isVertex1Inside && isVertex2Inside) { + const hit = lineEdgeIntersection({ + lineX, + lineY, + normalX, + normalY, + x1, + y1, + x2, + y2, + }); + if (hit) { + vertices.push([hit.x, hit.y]); + vertices.push([x2, y2]); + } + } } - } - } - if ( - vertices.length < 3 || - polygonArea({ vertices }) < MIN_POLYGON_AREA_PX - ) { - return new Path2D(); - } + if ( + vertices.length < 3 || + polygonArea({ vertices }) < MIN_POLYGON_AREA_PX + ) { + return new Path2D(); + } - const path = new Path2D(); - path.moveTo(vertices[0][0], vertices[0][1]); - for (let i = 1; i < vertices.length; i++) { - path.lineTo(vertices[i][0], vertices[i][1]); - } - path.closePath(); - return path; + const path = new Path2D(); + path.moveTo(vertices[0][0], vertices[0][1]); + for (let i = 1; i < vertices.length; i++) { + path.lineTo(vertices[i][0], vertices[i][1]); + } + path.closePath(); + return path; + }, + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const segment = getSplitMaskStrokeSegment({ - resolvedParams, - width, - height, - }); - const path = new Path2D(); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const segment = getSplitMaskStrokeSegment({ + resolvedParams, + width, + height, + }); + const path = new Path2D(); - if (!segment) { - return path; - } + if (!segment) { + return path; + } - path.moveTo(segment[0].x, segment[0].y); - path.lineTo(segment[1].x, segment[1].y); - return path; + path.moveTo(segment[0].x, segment[0].y); + path.lineTo(segment[1].x, segment[1].y); + return path; + }, }, }, }; diff --git a/apps/web/src/masks/definitions/star.ts b/apps/web/src/masks/builtin/definitions/star.ts index a7f8819c..62e9cbc4 100644 --- a/apps/web/src/masks/definitions/star.ts +++ b/apps/web/src/masks/builtin/definitions/star.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; const STAR_INNER_RADIUS_RATIO = 0.45; const STAR_VERTEX_COUNT = 10; @@ -85,7 +85,7 @@ function buildOverlayStarPath({ return `${segments.join(" ")} Z`; } -export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const starMaskDefinition: MaskDefinition<"star"> = { type: "star", name: "Star", features: { @@ -108,33 +108,39 @@ export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildStarPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildStarPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildStarPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildStarPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/text.ts b/apps/web/src/masks/builtin/definitions/text.ts index c285f1c8..db2af7e0 100644 --- a/apps/web/src/masks/definitions/text.ts +++ b/apps/web/src/masks/builtin/definitions/text.ts @@ -4,6 +4,7 @@ import type { MaskParamUpdateArgs, TextMask, TextMaskParams, + MaskHandleId, } from "@/masks/types"; import { DEFAULTS } from "@/timeline/defaults"; import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography"; @@ -35,7 +36,7 @@ const PERCENTAGE_DISPLAY = { step: 1, } as const; -const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign; +const TEXT_MASK_ALIGNMENT = "center"; const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [ { @@ -60,7 +61,7 @@ const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [ key: "fontSize", label: "Size", type: "number", - default: DEFAULTS.text.element.fontSize, + default: 15, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE, step: 1, @@ -124,9 +125,9 @@ function measureTextMask({ function getScalePreferredEdges({ handleId, }: { - handleId: string; + handleId: MaskHandleId; }): ScaleEdgePreference | undefined { - if (handleId !== "scale") { + if (handleId.kind !== "scale") { return undefined; } @@ -145,7 +146,7 @@ function computeTextMaskParamUpdate({ startCanvasY, bounds, }: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> { - if (handleId === "position") { + if (handleId.kind === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, centerY: startParams.centerY + deltaY / bounds.height, @@ -155,7 +156,7 @@ function computeTextMaskParamUpdate({ const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotY = bounds.cy + startParams.centerY * bounds.height; - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const startAngle = (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / Math.PI; @@ -176,7 +177,7 @@ function computeTextMaskParamUpdate({ }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -187,7 +188,7 @@ function computeTextMaskParamUpdate({ }); } - if (handleId === "scale") { + if (handleId.kind === "scale") { const startDistance = Math.hypot( startCanvasX - pivotX, startCanvasY - pivotY, @@ -205,7 +206,7 @@ function computeTextMaskParamUpdate({ return {}; } -export const textMaskDefinition: MaskDefinition<TextMaskParams> = { +export const textMaskDefinition: MaskDefinition<"text"> = { type: "text", name: "Text", features: { @@ -269,7 +270,7 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { y: proposedParams.centerY * bounds.height, }; - if (handleId === "position") { + if (handleId.kind === "position") { const { snappedPosition, activeLines } = snapPosition({ proposedPosition: position, canvasSize: bounds, @@ -297,7 +298,7 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { }; } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const { snappedRotation } = snapRotation({ proposedRotation: proposedParams.rotation, }); @@ -310,7 +311,7 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { }; } - if (handleId === "scale") { + if (handleId.kind === "scale") { const { snappedScale, activeLines } = snapScale({ proposedScale: proposedParams.scale, position, @@ -351,11 +352,11 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { strokeWidth: 0, strokeAlign: "center", content: "Mask", - fontSize: DEFAULTS.text.element.fontSize, - fontFamily: DEFAULTS.text.element.fontFamily, - fontWeight: DEFAULTS.text.element.fontWeight, - fontStyle: DEFAULTS.text.element.fontStyle, - textDecoration: DEFAULTS.text.element.textDecoration, + fontSize: 15, + fontFamily: "Arial", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", letterSpacing: DEFAULTS.text.letterSpacing, lineHeight: DEFAULTS.text.lineHeight, centerX: 0, @@ -370,69 +371,75 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { return params.content.trim().length > 0; }, renderer: { - renderMask({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as TextMaskParams; - const { layout } = measureTextMask({ params, height }); + body: { + kind: "drawOpaque", + drawOpaque({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + const { layout } = measureTextMask({ params, height }); - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } - drawMeasuredTextLayout({ - ctx, - layout, - textColor: "#ffffff", - background: null, - }); - ctx.restore(); - }, - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as TextMaskParams; - const { layout } = measureTextMask({ params, height }); - - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } - - strokeMeasuredTextLayout({ - ctx, - layout, - strokeColor: params.strokeColor, - strokeWidth: params.strokeWidth, - }); - - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; + ctx.save(); + ctx.translate( + width / 2 + params.centerX * width, + height / 2 + params.centerY * height, + ); + ctx.scale(params.scale, params.scale); + if (params.rotation) { + ctx.rotate((params.rotation * Math.PI) / 180); + } drawMeasuredTextLayout({ ctx, layout, textColor: "#ffffff", background: null, }); - } + ctx.restore(); + }, + }, + stroke: { + kind: "renderStroke", + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + const { layout } = measureTextMask({ params, height }); - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - drawMeasuredTextLayout({ + ctx.save(); + ctx.translate( + width / 2 + params.centerX * width, + height / 2 + params.centerY * height, + ); + ctx.scale(params.scale, params.scale); + if (params.rotation) { + ctx.rotate((params.rotation * Math.PI) / 180); + } + + strokeMeasuredTextLayout({ ctx, layout, - textColor: "#ffffff", - background: null, + strokeColor: params.strokeColor, + strokeWidth: params.strokeWidth, }); - } - ctx.restore(); + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + ctx.restore(); + }, }, }, }; diff --git a/apps/web/src/masks/components/masks-tab.tsx b/apps/web/src/masks/components/masks-tab.tsx index 97fba3ab..d6729d6d 100644 --- a/apps/web/src/masks/components/masks-tab.tsx +++ b/apps/web/src/masks/components/masks-tab.tsx @@ -3,7 +3,11 @@ import type { MaskableElement } from "@/timeline"; import type { Mask, MaskType, TextMask } from "@/masks/types"; import type { NumberParamDefinition, SelectParamDefinition } from "@/params"; -import { masksRegistry, buildDefaultMaskInstance } from "@/masks"; +import { + buildDefaultMaskInstance, + getMaskDefinition, + getMaskDefinitionsForMenu, +} from "@/masks"; import { useEditor } from "@/editor/use-editor"; import { useElementPreview } from "@/timeline/hooks/use-element-preview"; import { useMenuPreview } from "@/editor/use-menu-preview"; @@ -85,12 +89,43 @@ type PreviewParamHandler = ( key: string, ) => (value: number | string | boolean) => void; -type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>; +type RegisteredMaskDefinition = ReturnType<typeof getMaskDefinition>; function isTextMask(mask: Mask): mask is TextMask { return mask.type === "text"; } +function withPreviewedMaskParam({ + mask, + key, + value, +}: { + mask: Mask; + key: string; + value: number | string | boolean; +}): Mask { + switch (mask.type) { + case "split": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "cinematic-bars": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "rectangle": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "ellipse": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "heart": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "diamond": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "star": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "text": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "freeform": + return { ...mask, params: { ...mask.params, [key]: value } }; + } +} + export function MasksTab({ element, trackId }: MasksTabProps) { const editor = useEditor(); const { renderElement, previewUpdates, commit } = @@ -99,7 +134,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) { elementId: element.id, fallback: element, }); - const maskDefs = masksRegistry.getAll(); + const maskDefs = getMaskDefinitionsForMenu(); const tracks = useEditor( (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, ); @@ -210,16 +245,10 @@ export function MasksTab({ element, trackId }: MasksTabProps) { const updatedMasks = renderMasks.map((existingMask, maskIndex) => maskIndex !== index ? existingMask - : { - ...existingMask, - params: { - ...existingMask.params, - [key]: value, - }, - }, + : withPreviewedMaskParam({ mask: existingMask, key, value }), ); - previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>); + previewUpdates({ masks: updatedMasks }); }; return ( @@ -301,7 +330,7 @@ function MaskItem({ onCommit, }: MaskItemProps) { const editor = useEditor(); - const definition = masksRegistry.get(mask.type); + const definition = getMaskDefinition(mask.type); return ( <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}> diff --git a/apps/web/src/masks/definitions/ellipse.ts b/apps/web/src/masks/definitions/ellipse.ts deleted file mode 100644 index d0567817..00000000 --- a/apps/web/src/masks/definitions/ellipse.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, -} from "./box-like"; - -export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = { - type: "ellipse", - name: "Ellipse", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - buildOverlayPath({ width, height }) { - const rx = Math.max((width - 1) / 2, 0); - const ry = Math.max((height - 1) / 2, 0); - const cx = width / 2; - const cy = height / 2; - return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`; - }, - }), - buildDefault(context) { - return { - type: "ellipse", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const path = new Path2D(); - path.ellipse( - centerX, - centerY, - maskWidth / 2, - maskHeight / 2, - rotationRad, - 0, - Math.PI * 2, - ); - return path; - }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - const path = new Path2D(); - path.ellipse( - centerX, - centerY, - Math.max(1, maskWidth / 2 + offset), - Math.max(1, maskHeight / 2 + offset), - rotationRad, - 0, - Math.PI * 2, - ); - return path; - }, - }, -}; diff --git a/apps/web/src/masks/definitions/custom.ts b/apps/web/src/masks/freeform/definition.ts index cd6e2055..ae30110e 100644 --- a/apps/web/src/masks/definitions/custom.ts +++ b/apps/web/src/masks/freeform/definition.ts @@ -3,27 +3,26 @@ import type { ParamDefinition } from "@/params"; import { PEN_CURSOR } from "@/preview/components/cursors"; import type { ElementBounds } from "@/preview/element-bounds"; import type { - CustomMask, - CustomMaskParams, + FreeformPathMask, + FreeformPathMaskParams, MaskDefinition, MaskHandlePosition, MaskOverlay, MaskParamUpdateArgs, } from "@/masks/types"; import { - buildCustomMaskPath2D, - buildCustomMaskSvgPath, - customMaskCanvasPointToLocal, - findClosestPointOnCustomMaskSegment, - getCustomMaskCanvasSegments, - getCustomMaskCanvasGeometry, - getCustomMaskLocalBounds, - getCustomMaskSegmentCount, - insertPointIntoCustomMaskSegment, - parseCustomMaskHandleId, - recenterCustomMaskPath, - type CustomMaskPathPoint, -} from "@/masks/custom-path"; + buildFreeformPath2D, + buildFreeformSvgPath, + freeformCanvasPointToLocal, + findClosestPointOnFreeformSegment, + getFreeformCanvasSegments, + getFreeformCanvasGeometry, + getFreeformLocalBounds, + getFreeformSegmentCount, + insertPointIntoFreeformSegment, + recenterFreeformPath, + type FreeformPathPoint, +} from "@/masks/freeform/path"; import { getBoxMaskHandlePositions } from "@/masks/handle-positions"; import { computeFeatherUpdate } from "@/masks/param-update"; import { @@ -41,7 +40,7 @@ const PERCENTAGE_DISPLAY = { step: 1, } as const; -const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [ +const FREEFORM_PATH_MASK_PARAMS: ParamDefinition<keyof FreeformPathMaskParams & string>[] = [ { key: "centerX", label: "X", @@ -80,12 +79,12 @@ const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [ }, ]; -function getCustomMaskDisplayHandles({ +function getFreeformDisplayHandles({ params, displayScale, bounds, }: { - params: CustomMaskParams; + params: FreeformPathMaskParams; displayScale: number; bounds: ElementBounds; }): { @@ -93,7 +92,7 @@ function getCustomMaskDisplayHandles({ overlays: MaskOverlay[]; } { const points = params.path; - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX: params.centerX, centerY: params.centerY, @@ -109,7 +108,7 @@ function getCustomMaskDisplayHandles({ overlays.push({ id: "path", type: "canvas-path", - pathData: buildCustomMaskSvgPath({ + pathData: buildFreeformSvgPath({ points, centerX: params.centerX, centerY: params.centerY, @@ -125,7 +124,7 @@ function getCustomMaskDisplayHandles({ if (params.closed) { const segmentStrokeWidth = 12; overlays.push( - ...getCustomMaskCanvasSegments({ + ...getFreeformCanvasSegments({ points, centerX: params.centerX, centerY: params.centerY, @@ -133,12 +132,12 @@ function getCustomMaskDisplayHandles({ scale: params.scale, bounds, closed: true, - }).map((segment) => ({ + }).map((segment): MaskOverlay => ({ id: `segment:${segment.index}`, type: "canvas-path" as const, pathData: segment.pathData, coordinateSpace: "canvas" as const, - handleId: `segment:${segment.index}`, + handleId: { kind: "segment", index: segment.index }, cursor: PEN_CURSOR, strokeOpacity: 0, strokeWidth: segmentStrokeWidth, @@ -146,7 +145,7 @@ function getCustomMaskDisplayHandles({ ); } - const localBounds = getCustomMaskLocalBounds({ points, bounds }); + const localBounds = getFreeformLocalBounds({ points, bounds }); if (params.closed && localBounds) { handles.push( ...getBoxMaskHandlePositions({ @@ -166,7 +165,7 @@ function getCustomMaskDisplayHandles({ geometry.anchors.forEach((point) => { handles.push({ - id: `point:${point.id}:anchor`, + id: { kind: "anchor", pointId: point.id }, x: point.anchor.x, y: point.anchor.y, cursor: params.closed ? "move" : "pointer", @@ -180,19 +179,19 @@ function getCustomMaskDisplayHandles({ }; } -function updateCustomMaskPoint({ +function updateFreeformPathMaskPoint({ points, pointId, updater, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; pointId: string; - updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint; + updater: (point: FreeformPathPoint) => FreeformPathPoint; }) { return points.map((point) => (point.id === pointId ? updater(point) : point)); } -function computeCustomMaskParamUpdate({ +function computeFreeformParamUpdate({ handleId, startParams, deltaX, @@ -200,8 +199,8 @@ function computeCustomMaskParamUpdate({ startCanvasX, startCanvasY, bounds, -}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> { - if (handleId === "position") { +}: MaskParamUpdateArgs<FreeformPathMaskParams>): Partial<FreeformPathMaskParams> { + if (handleId.kind === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, centerY: startParams.centerY + deltaY / bounds.height, @@ -211,7 +210,7 @@ function computeCustomMaskParamUpdate({ const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotY = bounds.cy + startParams.centerY * bounds.height; - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const startAngle = (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / Math.PI; @@ -232,7 +231,7 @@ function computeCustomMaskParamUpdate({ }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -243,7 +242,7 @@ function computeCustomMaskParamUpdate({ }); } - if (handleId === "scale") { + if (handleId.kind === "scale") { const startDistance = Math.hypot( startCanvasX - pivotX, startCanvasY - pivotY, @@ -258,8 +257,7 @@ function computeCustomMaskParamUpdate({ }; } - const parsedHandle = parseCustomMaskHandleId({ handleId }); - if (!parsedHandle) { + if (handleId.kind !== "anchor") { return {}; } @@ -268,7 +266,7 @@ function computeCustomMaskParamUpdate({ x: startCanvasX + deltaX, y: startCanvasY + deltaY, }; - const localPoint = customMaskCanvasPointToLocal({ + const localPoint = freeformCanvasPointToLocal({ point: currentPoint, centerX: startParams.centerX, centerY: startParams.centerY, @@ -278,45 +276,27 @@ function computeCustomMaskParamUpdate({ }); return { - path: updateCustomMaskPoint({ + path: updateFreeformPathMaskPoint({ points, - pointId: parsedHandle.pointId, - updater: (point) => { - if (parsedHandle.part === "anchor") { - return { - ...point, - x: localPoint.x, - y: localPoint.y, - }; - } - - if (parsedHandle.part === "in") { - return { - ...point, - inX: localPoint.x - point.x, - inY: localPoint.y - point.y, - }; - } - - return { - ...point, - outX: localPoint.x - point.x, - outY: localPoint.y - point.y, - }; - }, + pointId: handleId.pointId, + updater: (point) => ({ + ...point, + x: localPoint.x, + y: localPoint.y, + }), }), }; } -export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { - type: "custom", - name: "Custom", +export const freeformMaskDefinition: MaskDefinition<"freeform"> = { + type: "freeform", + name: "Pen tool", features: { hasPosition: true, hasRotation: true, sizeMode: "uniform", }, - params: CUSTOM_MASK_PARAMS, + params: FREEFORM_PATH_MASK_PARAMS, interaction: { getInteraction({ params, @@ -325,7 +305,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { scaleX: _scaleX, scaleY: _scaleY, }) { - return getCustomMaskDisplayHandles({ params, bounds, displayScale }); + return getFreeformDisplayHandles({ params, bounds, displayScale }); }, snap({ handleId, @@ -336,7 +316,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { snapThreshold, }) { const points = startParams.path; - const localBounds = getCustomMaskLocalBounds({ points, bounds }); + const localBounds = getFreeformLocalBounds({ points, bounds }); if (!startParams.closed || !localBounds) { return { params: proposedParams, @@ -349,7 +329,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { y: proposedParams.centerY * bounds.height, }; - if (handleId === "position") { + if (handleId.kind === "position") { const { snappedPosition, activeLines } = snapPosition({ proposedPosition: position, canvasSize: bounds, @@ -377,7 +357,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { }; } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const { snappedRotation } = snapRotation({ proposedRotation: proposedParams.rotation, }); @@ -390,7 +370,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { }; } - if (handleId === "scale") { + if (handleId.kind === "scale") { const { snappedScale, activeLines } = snapScale({ proposedScale: proposedParams.scale, position, @@ -424,9 +404,9 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { }; }, }, - buildDefault(): Omit<CustomMask, "id"> { + buildDefault(): Omit<FreeformPathMask, "id"> { return { - type: "custom", + type: "freeform", params: { feather: 0, inverted: false, @@ -442,89 +422,95 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { }, }; }, - computeParamUpdate: computeCustomMaskParamUpdate, + computeParamUpdate: computeFreeformParamUpdate, isActive(params) { return params.closed; }, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as CustomMaskParams; - const points = params.path; - if (!params.closed) { - return new Path2D(); - } + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const points = params.path; + if (!params.closed) { + return new Path2D(); + } - return buildCustomMaskPath2D({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds: { - cx: width / 2, - cy: height / 2, - width, - height, - rotation: 0, - }, - closed: true, - }); + return buildFreeformPath2D({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds: { + cx: width / 2, + cy: height / 2, + width, + height, + rotation: 0, + }, + closed: true, + }); + }, }, - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as CustomMaskParams; - if (!params.closed) { - return; - } + stroke: { + kind: "renderStroke", + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + if (!params.closed) { + return; + } - const points = params.path; - const path = buildCustomMaskPath2D({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds: { - cx: width / 2, - cy: height / 2, - width, - height, - rotation: 0, - }, - closed: true, - }); + const points = params.path; + const path = buildFreeformPath2D({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds: { + cx: width / 2, + cy: height / 2, + width, + height, + rotation: 0, + }, + closed: true, + }); - ctx.save(); - ctx.strokeStyle = params.strokeColor; - ctx.lineWidth = params.strokeWidth; - ctx.lineJoin = "round"; - ctx.lineCap = "round"; - ctx.stroke(path); + ctx.save(); + ctx.strokeStyle = params.strokeColor; + ctx.lineWidth = params.strokeWidth; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.stroke(path); - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; - ctx.fillStyle = "#ffffff"; - ctx.fill(path); - } + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - ctx.fillStyle = "#ffffff"; - ctx.fill(path); - } - ctx.restore(); + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } + ctx.restore(); + }, }, }, }; -export function appendPointToCustomMask({ +export function appendPointToFreeformPathMask({ params, canvasPoint, bounds, }: { - params: CustomMaskParams; + params: FreeformPathMaskParams; canvasPoint: { x: number; y: number }; bounds: ElementBounds; -}): CustomMaskParams { +}): FreeformPathMaskParams { const points = params.path; if (points.length === 0) { @@ -550,7 +536,7 @@ export function appendPointToCustomMask({ }; } - const localPoint = customMaskCanvasPointToLocal({ + const localPoint = freeformCanvasPointToLocal({ point: canvasPoint, centerX: params.centerX, centerY: params.centerY, @@ -570,7 +556,7 @@ export function appendPointToCustomMask({ outY: 0, }, ]; - const recentered = recenterCustomMaskPath({ + const recentered = recenterFreeformPath({ points: nextPoints, centerX: params.centerX, centerY: params.centerY, @@ -587,25 +573,25 @@ export function appendPointToCustomMask({ }; } -export function insertPointOnCustomMaskSegment({ +export function insertPointOnFreeformSegment({ params, segmentIndex, canvasPoint, bounds, pointId = generateUUID(), }: { - params: CustomMaskParams; + params: FreeformPathMaskParams; segmentIndex: number; canvasPoint: { x: number; y: number }; bounds: ElementBounds; pointId?: string; -}): { params: CustomMaskParams; pointId: string } | null { +}): { params: FreeformPathMaskParams; pointId: string } | null { const points = params.path; - if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) { + if (getFreeformSegmentCount({ points, closed: params.closed }) === 0) { return null; } - const closestPoint = findClosestPointOnCustomMaskSegment({ + const closestPoint = findClosestPointOnFreeformSegment({ points, segmentIndex, canvasPoint, @@ -620,7 +606,7 @@ export function insertPointOnCustomMaskSegment({ return null; } - const nextPoints = insertPointIntoCustomMaskSegment({ + const nextPoints = insertPointIntoFreeformSegment({ points, segmentIndex, pointId, @@ -631,7 +617,7 @@ export function insertPointOnCustomMaskSegment({ return null; } - const recentered = recenterCustomMaskPath({ + const recentered = recenterFreeformPath({ points: nextPoints, centerX: params.centerX, centerY: params.centerY, diff --git a/apps/web/src/masks/custom-path.ts b/apps/web/src/masks/freeform/path.ts index 0349df63..fa6217c2 100644 --- a/apps/web/src/masks/custom-path.ts +++ b/apps/web/src/masks/freeform/path.ts @@ -1,6 +1,6 @@ import type { ElementBounds } from "@/preview/element-bounds"; -export interface CustomMaskPathPoint { +export interface FreeformPathPoint { id: string; x: number; y: number; @@ -10,88 +10,61 @@ export interface CustomMaskPathPoint { outY: number; } -export type CustomMaskHandlePart = "anchor" | "in" | "out"; - -function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { +function isFreeformPathPoint(value: unknown): value is FreeformPathPoint { if (!value || typeof value !== "object") { return false; } - const candidate = value as Record<string, unknown>; return ( - typeof candidate.id === "string" && - typeof candidate.x === "number" && - typeof candidate.y === "number" && - typeof candidate.inX === "number" && - typeof candidate.inY === "number" && - typeof candidate.outX === "number" && - typeof candidate.outY === "number" + "id" in value && + typeof value.id === "string" && + "x" in value && + typeof value.x === "number" && + "y" in value && + typeof value.y === "number" && + "inX" in value && + typeof value.inX === "number" && + "inY" in value && + typeof value.inY === "number" && + "outX" in value && + typeof value.outX === "number" && + "outY" in value && + typeof value.outY === "number" ); } -export function parseCustomMaskHandleId({ - handleId, -}: { - handleId: string; -}): { pointId: string; part: CustomMaskHandlePart } | null { - const match = /^point:(.+):(anchor|in|out)$/.exec(handleId); - if (!match) { - return null; - } - - return { - pointId: match[1], - part: match[2] as CustomMaskHandlePart, - }; -} - -export function parseCustomMaskSegmentHandleId({ - handleId, -}: { - handleId: string; -}): { segmentIndex: number } | null { - const match = /^segment:(\d+)$/.exec(handleId); - if (!match) { - return null; - } - - return { - segmentIndex: Number.parseInt(match[1], 10), - }; -} - -export function parseCustomMaskPath({ +export function parseFreeformPath({ path, }: { path: string; -}): CustomMaskPathPoint[] { +}): FreeformPathPoint[] { if (!path) { return []; } try { const parsed = JSON.parse(path); - return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; + return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : []; } catch { return []; } } -export function serializeCustomMaskPath({ +export function serializeFreeformPath({ points, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; }): string { return JSON.stringify(points); } -export function removeCustomMaskPoints({ +export function removeFreeformPathPoints({ points, pointIds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; pointIds: string[]; -}): CustomMaskPathPoint[] { +}): FreeformPathPoint[] { if (pointIds.length === 0) { return points; } @@ -99,7 +72,7 @@ export function removeCustomMaskPoints({ return points.filter((point) => !pointIdsToRemove.has(point.id)); } -export function getCustomMaskClosedStateAfterPointRemoval({ +export function getFreeformPathClosedStateAfterPointRemoval({ wasClosed, remainingPointCount, }: { @@ -127,7 +100,7 @@ function rotatePoint({ }; } -export function getCustomMaskCenterCanvasPoint({ +export function getFreeformCenterCanvasPoint({ centerX, centerY, bounds, @@ -142,7 +115,7 @@ export function getCustomMaskCenterCanvasPoint({ }; } -export function customMaskLocalPointToCanvas({ +export function freeformLocalPointToCanvas({ point, centerX, centerY, @@ -157,7 +130,7 @@ export function customMaskLocalPointToCanvas({ scale: number; bounds: ElementBounds; }): { x: number; y: number } { - const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); + const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds }); const scaledLocal = { x: point.x * bounds.width * scale, y: point.y * bounds.height * scale, @@ -174,7 +147,7 @@ export function customMaskLocalPointToCanvas({ }; } -export function customMaskCanvasPointToLocal({ +export function freeformCanvasPointToLocal({ point, centerX, centerY, @@ -189,7 +162,7 @@ export function customMaskCanvasPointToLocal({ scale: number; bounds: ElementBounds; }): { x: number; y: number } { - const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); + const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds }); const translated = { x: point.x - center.x, y: point.y - center.y, @@ -206,7 +179,7 @@ export function customMaskCanvasPointToLocal({ }; } -export function getCustomMaskCanvasGeometry({ +export function getFreeformCanvasGeometry({ points, centerX, centerY, @@ -214,7 +187,7 @@ export function getCustomMaskCanvasGeometry({ scale, bounds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -223,7 +196,7 @@ export function getCustomMaskCanvasGeometry({ }) { const anchors = points.map((point) => ({ id: point.id, - anchor: customMaskLocalPointToCanvas({ + anchor: freeformLocalPointToCanvas({ point: { x: point.x, y: point.y }, centerX, centerY, @@ -231,7 +204,7 @@ export function getCustomMaskCanvasGeometry({ scale, bounds, }), - inHandle: customMaskLocalPointToCanvas({ + inHandle: freeformLocalPointToCanvas({ point: { x: point.x + point.inX, y: point.y + point.inY }, centerX, centerY, @@ -239,7 +212,7 @@ export function getCustomMaskCanvasGeometry({ scale, bounds, }), - outHandle: customMaskLocalPointToCanvas({ + outHandle: freeformLocalPointToCanvas({ point: { x: point.x + point.outX, y: point.y + point.outY }, centerX, centerY, @@ -315,7 +288,7 @@ function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): { }; } -export interface CustomMaskCanvasSegment { +export interface FreeformCanvasSegment { index: number; startPointId: string; endPointId: string; @@ -330,13 +303,27 @@ function clampUnit(value: number): number { return Math.min(1, Math.max(0, value)); } -function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number { +function getDistanceSquared({ + a, + b, +}: { + a: CanvasPoint; + b: CanvasPoint; +}): number { const dx = a.x - b.x; const dy = a.y - b.y; return dx * dx + dy * dy; } -function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint { +function lerpPoint({ + a, + b, + t, +}: { + a: CanvasPoint; + b: CanvasPoint; + t: number; +}): CanvasPoint { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, @@ -371,16 +358,16 @@ function evaluateCubicBezier({ }; } -function getCustomMaskSegmentIndices({ +function getFreeformSegmentIndices({ points, segmentIndex, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; segmentIndex: number; closed: boolean; }): { startIndex: number; endIndex: number } | null { - const segmentCount = getCustomMaskSegmentCount({ points, closed }); + const segmentCount = getFreeformSegmentCount({ points, closed }); if (segmentIndex < 0 || segmentIndex >= segmentCount) { return null; } @@ -391,11 +378,11 @@ function getCustomMaskSegmentIndices({ }; } -export function getCustomMaskSegmentCount({ +export function getFreeformSegmentCount({ points, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; closed: boolean; }): number { if (points.length < 2) { @@ -404,7 +391,7 @@ export function getCustomMaskSegmentCount({ return closed ? points.length : points.length - 1; } -export function getCustomMaskCanvasSegments({ +export function getFreeformCanvasSegments({ points, centerX, centerY, @@ -413,15 +400,15 @@ export function getCustomMaskCanvasSegments({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; closed: boolean; -}): CustomMaskCanvasSegment[] { - const geometry = getCustomMaskCanvasGeometry({ +}): FreeformCanvasSegment[] { + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, @@ -429,7 +416,7 @@ export function getCustomMaskCanvasSegments({ scale, bounds, }); - const segmentCount = getCustomMaskSegmentCount({ points, closed }); + const segmentCount = getFreeformSegmentCount({ points, closed }); return Array.from({ length: segmentCount }, (_, segmentIndex) => { const start = geometry.anchors[segmentIndex]; @@ -447,7 +434,7 @@ export function getCustomMaskCanvasSegments({ }); } -export function findClosestPointOnCustomMaskSegment({ +export function findClosestPointOnFreeformSegment({ points, segmentIndex, canvasPoint, @@ -458,7 +445,7 @@ export function findClosestPointOnCustomMaskSegment({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; segmentIndex: number; canvasPoint: CanvasPoint; centerX: number; @@ -468,7 +455,7 @@ export function findClosestPointOnCustomMaskSegment({ bounds: ElementBounds; closed: boolean; }): { t: number; point: CanvasPoint } | null { - const segment = getCustomMaskCanvasSegments({ + const segment = getFreeformCanvasSegments({ points, centerX, centerY, @@ -483,7 +470,10 @@ export function findClosestPointOnCustomMaskSegment({ const sampleCount = 24; let bestT = 0; - let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start); + let bestDistanceSquared = getDistanceSquared({ + a: canvasPoint, + b: segment.start, + }); for (let step = 0; step <= sampleCount; step++) { const t = step / sampleCount; @@ -494,7 +484,7 @@ export function findClosestPointOnCustomMaskSegment({ p3: segment.end, t, }); - const distanceSquared = getDistanceSquared(canvasPoint, point); + const distanceSquared = getDistanceSquared({ a: canvasPoint, b: point }); if (distanceSquared < bestDistanceSquared) { bestDistanceSquared = distanceSquared; bestT = t; @@ -516,7 +506,10 @@ export function findClosestPointOnCustomMaskSegment({ }), })); for (const candidate of candidates) { - const distanceSquared = getDistanceSquared(canvasPoint, candidate.point); + const distanceSquared = getDistanceSquared({ + a: canvasPoint, + b: candidate.point, + }); if (distanceSquared < bestDistanceSquared) { bestDistanceSquared = distanceSquared; bestT = candidate.t; @@ -538,20 +531,20 @@ export function findClosestPointOnCustomMaskSegment({ }; } -export function insertPointIntoCustomMaskSegment({ +export function insertPointIntoFreeformSegment({ points, segmentIndex, pointId, t, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; segmentIndex: number; pointId: string; t: number; closed: boolean; -}): CustomMaskPathPoint[] { - const indices = getCustomMaskSegmentIndices({ +}): FreeformPathPoint[] { + const indices = getFreeformSegmentIndices({ points, segmentIndex, closed, @@ -573,12 +566,12 @@ export function insertPointIntoCustomMaskSegment({ y: endPoint.y + endPoint.inY, }; const p3 = { x: endPoint.x, y: endPoint.y }; - const p01 = lerpPoint(p0, p1, clampedT); - const p12 = lerpPoint(p1, p2, clampedT); - const p23 = lerpPoint(p2, p3, clampedT); - const p012 = lerpPoint(p01, p12, clampedT); - const p123 = lerpPoint(p12, p23, clampedT); - const splitPoint = lerpPoint(p012, p123, clampedT); + const p01 = lerpPoint({ a: p0, b: p1, t: clampedT }); + const p12 = lerpPoint({ a: p1, b: p2, t: clampedT }); + const p23 = lerpPoint({ a: p2, b: p3, t: clampedT }); + const p012 = lerpPoint({ a: p01, b: p12, t: clampedT }); + const p123 = lerpPoint({ a: p12, b: p23, t: clampedT }); + const splitPoint = lerpPoint({ a: p012, b: p123, t: clampedT }); const nextPoints = [...points]; nextPoints[indices.startIndex] = { @@ -603,11 +596,11 @@ export function insertPointIntoCustomMaskSegment({ return nextPoints; } -export function getCustomMaskLocalBounds({ +export function getFreeformLocalBounds({ points, bounds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; bounds: ElementBounds; }) { if (points.length === 0) { @@ -639,7 +632,7 @@ export function getCustomMaskLocalBounds({ }; } -export function recenterCustomMaskPath({ +export function recenterFreeformPath({ points, centerX, centerY, @@ -647,7 +640,7 @@ export function recenterCustomMaskPath({ scale, bounds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -658,7 +651,7 @@ export function recenterCustomMaskPath({ return { centerX, centerY, points }; } - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, @@ -683,7 +676,7 @@ export function recenterCustomMaskPath({ }; const nextPoints = geometry.anchors.map((point) => { - const anchor = customMaskCanvasPointToLocal({ + const anchor = freeformCanvasPointToLocal({ point: point.anchor, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, @@ -691,7 +684,7 @@ export function recenterCustomMaskPath({ scale, bounds, }); - const inHandle = customMaskCanvasPointToLocal({ + const inHandle = freeformCanvasPointToLocal({ point: point.inHandle, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, @@ -699,7 +692,7 @@ export function recenterCustomMaskPath({ scale, bounds, }); - const outHandle = customMaskCanvasPointToLocal({ + const outHandle = freeformCanvasPointToLocal({ point: point.outHandle, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, @@ -726,7 +719,7 @@ export function recenterCustomMaskPath({ }; } -export function buildCustomMaskPath2D({ +export function buildFreeformPath2D({ points, centerX, centerY, @@ -735,7 +728,7 @@ export function buildCustomMaskPath2D({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -748,7 +741,7 @@ export function buildCustomMaskPath2D({ return path; } - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, @@ -789,7 +782,7 @@ export function buildCustomMaskPath2D({ return path; } -export function buildCustomMaskSvgPath({ +export function buildFreeformSvgPath({ points, centerX, centerY, @@ -798,7 +791,7 @@ export function buildCustomMaskSvgPath({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -810,7 +803,7 @@ export function buildCustomMaskSvgPath({ return ""; } - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, diff --git a/apps/web/src/masks/handle-positions.ts b/apps/web/src/masks/handle-positions.ts index 75668959..70039d9b 100644 --- a/apps/web/src/masks/handle-positions.ts +++ b/apps/web/src/masks/handle-positions.ts @@ -2,6 +2,7 @@ import { FEATHER_HANDLE_SCALE } from "@/masks/feather"; import type { ElementBounds } from "@/preview/element-bounds"; import type { MaskFeatures, + MaskHandleId, MaskHandlePosition, MaskLineOverlay, MaskOverlay, @@ -68,14 +69,14 @@ export function getLineMaskOverlay({ centerY, rotation, bounds, - handleId = "position", + handleId = { kind: "position" }, cursor = "move", }: { centerX: number; centerY: number; rotation: number; bounds: ElementBounds; - handleId?: string; + handleId?: MaskHandleId; cursor?: string; }): MaskLineOverlay { const { start, end } = getLineMaskLinePoints({ @@ -122,7 +123,7 @@ export function getLineMaskHandlePositions({ return [ { - id: "rotation", + id: { kind: "rotation" }, x: cx + normalX * iconOffsetCanvas, y: cy + normalY * iconOffsetCanvas, cursor: CURSOR.rotate, @@ -130,7 +131,7 @@ export function getLineMaskHandlePositions({ icon: "rotate", }, { - id: "feather", + id: { kind: "feather" }, x: cx - normalX * featherOffset, y: cy - normalY * featherOffset, cursor: CURSOR.resizeHorizontal, @@ -201,7 +202,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "rotation", + id: { kind: "rotation" }, x: rotHandle.x, y: rotHandle.y, cursor: CURSOR.rotate, @@ -217,7 +218,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "feather", + id: { kind: "feather" }, x: featherHandle.x, y: featherHandle.y, cursor: CURSOR.resizeVertical, @@ -226,16 +227,36 @@ export function getBoxMaskHandlePositions({ }); if (sizeMode === "width-height") { - const corners = [ - { localX: -halfWidth, localY: -halfHeight, id: "top-left" }, - { localX: halfWidth, localY: -halfHeight, id: "top-right" }, - { localX: halfWidth, localY: halfHeight, id: "bottom-right" }, - { localX: -halfWidth, localY: halfHeight, id: "bottom-left" }, + const corners: { + localX: number; + localY: number; + corner: Extract<MaskHandleId, { kind: "corner" }>["corner"]; + }[] = [ + { + localX: -halfWidth, + localY: -halfHeight, + corner: { x: "left", y: "top" }, + }, + { + localX: halfWidth, + localY: -halfHeight, + corner: { x: "right", y: "top" }, + }, + { + localX: halfWidth, + localY: halfHeight, + corner: { x: "right", y: "bottom" }, + }, + { + localX: -halfWidth, + localY: halfHeight, + corner: { x: "left", y: "bottom" }, + }, ]; - for (const { localX, localY, id } of corners) { + for (const { localX, localY, corner } of corners) { const point = rotatePoint({ localX, localY, cx, cy, angleRad }); handles.push({ - id, + id: { kind: "corner", corner }, x: point.x, y: point.y, cursor: CURSOR.resizeDiagonal, @@ -264,7 +285,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "left", + id: { kind: "edge", side: "left" }, x: left.x, y: left.y, cursor: CURSOR.resizeHorizontal, @@ -273,7 +294,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "right", + id: { kind: "edge", side: "right" }, x: right.x, y: right.y, cursor: CURSOR.resizeHorizontal, @@ -282,7 +303,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "bottom", + id: { kind: "edge", side: "bottom" }, x: bottom.x, y: bottom.y, cursor: CURSOR.resizeVertical, @@ -306,7 +327,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "top", + id: { kind: "edge", side: "top" }, x: top.x, y: top.y, cursor: CURSOR.resizeVertical, @@ -315,7 +336,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "bottom", + id: { kind: "edge", side: "bottom" }, x: bottom.x, y: bottom.y, cursor: CURSOR.resizeVertical, @@ -339,7 +360,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "left", + id: { kind: "edge", side: "left" }, x: left.x, y: left.y, cursor: CURSOR.resizeHorizontal, @@ -348,7 +369,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "right", + id: { kind: "edge", side: "right" }, x: right.x, y: right.y, cursor: CURSOR.resizeHorizontal, @@ -365,7 +386,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "scale", + id: { kind: "scale" }, x: point.x, y: point.y, cursor: CURSOR.resizeDiagonal, @@ -383,7 +404,7 @@ export function getBoxMaskRectOverlay({ height, rotation, bounds, - handleId = "position", + handleId = { kind: "position" }, cursor = "move", dashed = false, }: { @@ -393,7 +414,7 @@ export function getBoxMaskRectOverlay({ height: number; rotation: number; bounds: ElementBounds; - handleId?: string; + handleId?: MaskHandleId; cursor?: string; dashed?: boolean; }): MaskRectOverlay { @@ -421,7 +442,7 @@ export function getBoxMaskShapeOverlay({ rotation, bounds, pathData, - handleId = "position", + handleId = { kind: "position" }, cursor = "move", }: { centerX: number; @@ -431,7 +452,7 @@ export function getBoxMaskShapeOverlay({ rotation: number; bounds: ElementBounds; pathData: string; - handleId?: string; + handleId?: MaskHandleId; cursor?: string; }): MaskShapeOverlay { return { diff --git a/apps/web/src/masks/index.ts b/apps/web/src/masks/index.ts index 3544e239..994f05f2 100644 --- a/apps/web/src/masks/index.ts +++ b/apps/web/src/masks/index.ts @@ -1,18 +1,58 @@ -import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
-import { masksRegistry } from "./registry";
-import { generateUUID } from "@/utils/id";
-
-export { masksRegistry } from "./registry";
-export { registerDefaultMasks } from "./definitions";
-
-export function buildDefaultMaskInstance({
- maskType,
- elementSize,
-}: {
- maskType: MaskType;
- elementSize?: { width: number; height: number };
-}): Mask {
- const definition = masksRegistry.get(maskType);
- const context: MaskDefaultContext = { elementSize };
- return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
-}
+import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types"; +import { masksRegistry } from "./registry"; +import { generateUUID } from "@/utils/id"; + +export { masksRegistry } from "./registry"; +export { registerDefaultMasks } from "./builtin/definitions"; + +type MaskWithoutId = Mask extends infer TMask + ? TMask extends Mask + ? Omit<TMask, "id"> + : never + : never; + +function withMaskId({ mask, id }: { mask: MaskWithoutId; id: string }): Mask { + switch (mask.type) { + case "split": + return { ...mask, id }; + case "cinematic-bars": + return { ...mask, id }; + case "rectangle": + return { ...mask, id }; + case "ellipse": + return { ...mask, id }; + case "heart": + return { ...mask, id }; + case "diamond": + return { ...mask, id }; + case "star": + return { ...mask, id }; + case "text": + return { ...mask, id }; + case "freeform": + return { ...mask, id }; + } +} + +export function getMaskDefinition(maskType: MaskType) { + return masksRegistry.get(maskType); +} + +export function getMaskDefinitionsForMenu() { + return masksRegistry.getAll(); +} + +export function buildDefaultMaskInstance({ + maskType, + elementSize, +}: { + maskType: MaskType; + elementSize?: { width: number; height: number }; +}): Mask { + const definition = masksRegistry.get(maskType); + const context: MaskDefaultContext = { elementSize }; + return withMaskId({ + mask: definition.buildDefault(context), + id: generateUUID(), + }); +} diff --git a/apps/web/src/masks/registry.ts b/apps/web/src/masks/registry.ts index 00b276c8..35d215c6 100644 --- a/apps/web/src/masks/registry.ts +++ b/apps/web/src/masks/registry.ts @@ -2,11 +2,11 @@ import { MAX_FEATHER } from "@/masks/feather"; import type { ParamDefinition } from "@/params"; import type { BaseMaskParams, + Mask, MaskDefaultContext, MaskDefinition, - MaskInteractionResult, - MaskSnapArgs, - MaskSnapResult, + MaskParamUpdateArgs, + MaskRenderer, MaskType, } from "@/masks/types"; import type { HugeiconsIconProps } from "@hugeicons/react"; @@ -17,7 +17,17 @@ export type MaskIconProps = { strokeWidth?: number; }; -const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< +type RegisteredMaskWithoutId = Mask extends infer TMask + ? TMask extends Mask + ? Omit<TMask, "id"> + : never + : never; + +export type MaskDefinitionForRegistration = { + [TType in MaskType]: MaskDefinition<TType>; +}[MaskType]; + +export const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< keyof BaseMaskParams & string >[] = [ { @@ -50,28 +60,15 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< export interface RegisteredMaskDefinition { type: MaskType; name: string; - features: MaskDefinition<BaseMaskParams>["features"]; + features: MaskDefinition["features"]; params: ParamDefinition<string>[]; - renderer: MaskDefinition<BaseMaskParams>["renderer"]; - interaction: { - getInteraction(args: { - params: BaseMaskParams; - bounds: Parameters< - MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"] - >[0]["bounds"]; - displayScale: number; - scaleX: number; - scaleY: number; - }): MaskInteractionResult; - snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>; - }; - isActive?: (params: BaseMaskParams) => boolean; - buildDefault( - context: MaskDefaultContext, - ): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>; + renderer: MaskRenderer<BaseMaskParams>; + interaction: MaskDefinition["interaction"]; + isActive?(params: BaseMaskParams): boolean; + buildDefault(context: MaskDefaultContext): RegisteredMaskWithoutId; computeParamUpdate( - args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0], - ): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>; + args: MaskParamUpdateArgs<BaseMaskParams>, + ): Partial<BaseMaskParams>; icon: MaskIconProps; } @@ -83,11 +80,11 @@ export class MasksRegistry extends DefinitionRegistry< super("mask"); } - registerMask<TParams extends BaseMaskParams>({ + registerMask({ definition, icon, }: { - definition: MaskDefinition<TParams>; + definition: MaskDefinitionForRegistration; icon: MaskIconProps; }): void { const withBaseParams: RegisteredMaskDefinition = { @@ -96,26 +93,16 @@ export class MasksRegistry extends DefinitionRegistry< features: definition.features, params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS], renderer: definition.renderer, - interaction: { - getInteraction(args) { - return definition.interaction.getInteraction(args as never); - }, - snap: definition.interaction.snap - ? (args) => definition.interaction.snap?.(args as never) as never - : undefined, - }, - isActive: definition.isActive - ? (params) => definition.isActive?.(params as TParams) ?? true - : undefined, - buildDefault(context) { - return definition.buildDefault(context); - }, - computeParamUpdate(args) { - return definition.computeParamUpdate(args as never); - }, + interaction: definition.interaction, + isActive: definition.isActive, + buildDefault: definition.buildDefault, + computeParamUpdate: definition.computeParamUpdate, icon, }; - this.register(definition.type, withBaseParams); + this.register({ + key: definition.type, + definition: withBaseParams, + }); } } diff --git a/apps/web/src/masks/snap.ts b/apps/web/src/masks/snap.ts index cff62ebb..519783a9 100644 --- a/apps/web/src/masks/snap.ts +++ b/apps/web/src/masks/snap.ts @@ -6,30 +6,20 @@ import { snapScale, snapScaleAxes, type ScaleEdgePreference, - type SnapLine, } from "@/preview/preview-snap"; -import type { RectangleMaskParams, SplitMaskParams } from "@/masks/types"; +import type { + MaskHandleId, + MaskSnapArgs, + MaskSnapResult, + RectangleMaskParams, + SplitMaskParams, +} from "@/masks/types"; import { - isRectangleMaskParams, getMaskSnapGeometry, setMaskLocalCenter, toGlobalMaskSnapLines, } from "./geometry"; -type SharedMaskParams = SplitMaskParams | RectangleMaskParams; - -type MaskSnapResult<TParams extends SharedMaskParams> = { - params: TParams; - activeLines: SnapLine[]; -}; - -const CORNER_SIZE_HANDLES = new Set([ - "top-left", - "top-right", - "bottom-left", - "bottom-right", -]); - function getClampedRatio({ next, base, @@ -45,28 +35,27 @@ function getClampedRatio({ function getPreferredEdges({ handleId, }: { - handleId: string; + handleId: MaskHandleId; }): ScaleEdgePreference | undefined { - const preferredEdges = { - left: - handleId === "left" || - handleId === "top-left" || - handleId === "bottom-left", - right: - handleId === "right" || - handleId === "top-right" || - handleId === "bottom-right", - top: - handleId === "top" || handleId === "top-left" || handleId === "top-right", - bottom: - handleId === "bottom" || - handleId === "bottom-left" || - handleId === "bottom-right", - } satisfies ScaleEdgePreference; + if (handleId.kind === "edge") { + return { + left: handleId.side === "left", + right: handleId.side === "right", + top: handleId.side === "top", + bottom: handleId.side === "bottom", + }; + } - return Object.values(preferredEdges).some(Boolean) - ? preferredEdges - : undefined; + if (handleId.kind === "corner") { + return { + left: handleId.corner.x === "left", + right: handleId.corner.x === "right", + top: handleId.corner.y === "top", + bottom: handleId.corner.y === "bottom", + }; + } + + return undefined; } function snapMaskPosition({ @@ -75,11 +64,33 @@ function snapMaskPosition({ canvasSize, snapThreshold, }: { - proposedParams: SharedMaskParams; + proposedParams: RectangleMaskParams; bounds: ElementBounds; canvasSize: { width: number; height: number }; snapThreshold: { x: number; y: number }; -}): MaskSnapResult<SharedMaskParams> { +}): MaskSnapResult<RectangleMaskParams>; +function snapMaskPosition({ + proposedParams, + bounds, + canvasSize, + snapThreshold, +}: { + proposedParams: SplitMaskParams; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +}): MaskSnapResult<SplitMaskParams>; +function snapMaskPosition({ + proposedParams, + bounds, + canvasSize, + snapThreshold, +}: { + proposedParams: RectangleMaskParams | SplitMaskParams; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +}): MaskSnapResult<RectangleMaskParams | SplitMaskParams> { const geometry = getMaskSnapGeometry({ params: proposedParams, bounds, @@ -115,12 +126,18 @@ function snapMaskPosition({ function snapMaskRotation({ proposedParams, }: { - proposedParams: SharedMaskParams; -}): MaskSnapResult<SharedMaskParams> { - if (typeof proposedParams.rotation !== "number") { - return { params: proposedParams, activeLines: [] }; - } - + proposedParams: RectangleMaskParams; +}): MaskSnapResult<RectangleMaskParams>; +function snapMaskRotation({ + proposedParams, +}: { + proposedParams: SplitMaskParams; +}): MaskSnapResult<SplitMaskParams>; +function snapMaskRotation({ + proposedParams, +}: { + proposedParams: RectangleMaskParams | SplitMaskParams; +}): MaskSnapResult<RectangleMaskParams | SplitMaskParams> { const { snappedRotation } = snapRotation({ proposedRotation: proposedParams.rotation, }); @@ -142,20 +159,13 @@ function snapBoxMaskSize({ canvasSize, snapThreshold, }: { - handleId: string; - startParams: SharedMaskParams; - proposedParams: SharedMaskParams; + handleId: MaskHandleId; + startParams: RectangleMaskParams; + proposedParams: RectangleMaskParams; bounds: ElementBounds; canvasSize: { width: number; height: number }; snapThreshold: { x: number; y: number }; -}): MaskSnapResult<SharedMaskParams> { - if ( - !isRectangleMaskParams(startParams) || - !isRectangleMaskParams(proposedParams) - ) { - return { params: proposedParams, activeLines: [] }; - } - +}): MaskSnapResult<RectangleMaskParams> { const geometry = getMaskSnapGeometry({ params: proposedParams, bounds, @@ -171,7 +181,10 @@ function snapBoxMaskSize({ Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height; const preferredEdges = getPreferredEdges({ handleId }); - if (handleId === "right" || handleId === "left") { + if ( + handleId.kind === "edge" && + (handleId.side === "right" || handleId.side === "left") + ) { const proposedScaleX = getClampedRatio({ next: proposedParams.width, base: startParams.width, @@ -201,7 +214,10 @@ function snapBoxMaskSize({ }; } - if (handleId === "top" || handleId === "bottom") { + if ( + handleId.kind === "edge" && + (handleId.side === "top" || handleId.side === "bottom") + ) { const proposedScaleY = getClampedRatio({ next: proposedParams.height, base: startParams.height, @@ -234,7 +250,7 @@ function snapBoxMaskSize({ }; } - if (handleId === "scale") { + if (handleId.kind === "scale") { const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION); const proposedScale = getClampedRatio({ next: proposedParams.scale, @@ -264,7 +280,7 @@ function snapBoxMaskSize({ }; } - if (CORNER_SIZE_HANDLES.has(handleId)) { + if (handleId.kind === "corner") { const proposedScale = getClampedRatio({ next: proposedParams.width, base: startParams.width, @@ -297,7 +313,7 @@ function snapBoxMaskSize({ return { params: proposedParams, activeLines: [] }; } -export function snapMaskInteraction<TParams extends SharedMaskParams>({ +export function snapBoxMaskInteraction({ handleId, startParams, proposedParams, @@ -305,24 +321,24 @@ export function snapMaskInteraction<TParams extends SharedMaskParams>({ canvasSize, snapThreshold, }: { - handleId: string; - startParams: TParams; - proposedParams: TParams; + handleId: MaskHandleId; + startParams: RectangleMaskParams; + proposedParams: RectangleMaskParams; bounds: ElementBounds; canvasSize: { width: number; height: number }; snapThreshold: { x: number; y: number }; -}): MaskSnapResult<TParams> { - if (handleId === "position") { +}): MaskSnapResult<RectangleMaskParams> { + if (handleId.kind === "position") { return snapMaskPosition({ proposedParams, bounds, canvasSize, snapThreshold, - }) as MaskSnapResult<TParams>; + }); } - if (handleId === "rotation") { - return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>; + if (handleId.kind === "rotation") { + return snapMaskRotation({ proposedParams }); } return snapBoxMaskSize({ @@ -332,5 +348,28 @@ export function snapMaskInteraction<TParams extends SharedMaskParams>({ bounds, canvasSize, snapThreshold, - }) as MaskSnapResult<TParams>; + }); +} + +export function snapSplitMaskInteraction({ + handleId, + proposedParams, + bounds, + canvasSize, + snapThreshold, +}: MaskSnapArgs<SplitMaskParams>): MaskSnapResult<SplitMaskParams> { + if (handleId.kind === "position") { + return snapMaskPosition({ + proposedParams, + bounds, + canvasSize, + snapThreshold, + }); + } + + if (handleId.kind === "rotation") { + return snapMaskRotation({ proposedParams }); + } + + return { params: proposedParams, activeLines: [] }; } diff --git a/apps/web/src/masks/types.ts b/apps/web/src/masks/types.ts index 4f6c90aa..afc79a8c 100644 --- a/apps/web/src/masks/types.ts +++ b/apps/web/src/masks/types.ts @@ -1,14 +1,14 @@ import type { ElementBounds } from "@/preview/element-bounds"; import type { SnapLine } from "@/preview/preview-snap"; import type { ParamDefinition } from "@/params"; -import type { CustomMaskPathPoint } from "@/masks/custom-path"; +import type { FreeformPathPoint } from "@/masks/freeform/path"; import type { TextDecoration, TextFontStyle, TextFontWeight, } from "@/text/primitives"; -export type MaskType = +export type BuiltinMaskType = | "split" | "cinematic-bars" | "rectangle" @@ -16,8 +16,9 @@ export type MaskType = | "heart" | "diamond" | "star" - | "text" - | "custom"; + | "text"; + +export type MaskType = BuiltinMaskType | "freeform"; export interface BaseMaskParams { feather: number; @@ -57,8 +58,8 @@ export interface TextMaskParams extends BaseMaskParams { scale: number; } -export interface CustomMaskParams extends BaseMaskParams { - path: CustomMaskPathPoint[]; +export interface FreeformPathMaskParams extends BaseMaskParams { + path: FreeformPathPoint[]; closed: boolean; centerX: number; centerY: number; @@ -114,13 +115,7 @@ export interface TextMask { params: TextMaskParams; } -export interface CustomMask { - id: string; - type: "custom"; - params: CustomMaskParams; -} - -export type Mask = +export type BuiltinShapeMask = | SplitMask | CinematicBarsMask | RectangleMask @@ -128,35 +123,60 @@ export type Mask = | HeartMask | DiamondMask | StarMask - | TextMask - | CustomMask; + | TextMask; + +export interface FreeformPathMask { + id: string; + type: "freeform"; + params: FreeformPathMaskParams; +} + +export type Mask = BuiltinShapeMask | FreeformPathMask; + +export type MaskByType<TType extends MaskType> = Extract<Mask, { type: TType }>; +export type MaskParamsByType<TType extends MaskType> = + MaskByType<TType>["params"]; + +type MaskPathArgs<TParams extends BaseMaskParams> = { + resolvedParams: TParams; + width: number; + height: number; +}; + +type MaskDrawArgs<TParams extends BaseMaskParams> = MaskPathArgs<TParams> & { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +}; + +export type MaskBody<TParams extends BaseMaskParams = BaseMaskParams> = + | { + kind: "fillPath"; + buildPath(args: MaskPathArgs<TParams>): Path2D; + } + | { + kind: "drawOpaque"; + drawOpaque(args: MaskDrawArgs<TParams>): void; + } + | { + kind: "drawWithFeather"; + drawWithFeather(args: MaskDrawArgs<TParams> & { feather: number }): void; + opaqueFastPath?: { + buildPath(args: MaskPathArgs<TParams>): Path2D; + }; + }; -export interface MaskRenderer { - buildPath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - buildStrokePath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - /** Renders the feathered mask directly onto ctx, bypassing JFA. */ - renderMask?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - feather: number; - }) => void; - renderMaskHandlesFeather?: boolean; - renderStroke?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - }) => void; +export type MaskStroke<TParams extends BaseMaskParams = BaseMaskParams> = + | { + kind: "strokeFromPath"; + buildStrokePath(args: MaskPathArgs<TParams>): Path2D; + } + | { + kind: "renderStroke"; + renderStroke(args: MaskDrawArgs<TParams>): void; + }; + +export interface MaskRenderer<TParams extends BaseMaskParams = BaseMaskParams> { + body: MaskBody<TParams>; + stroke?: MaskStroke<TParams>; } export interface MaskFeatures { @@ -167,10 +187,41 @@ export interface MaskFeatures { export type MaskHandleIcon = "rotate" | "feather"; -export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent"; +export type MaskHandleKind = "corner" | "edge" | "icon" | "point"; + +type Side = "left" | "right" | "top" | "bottom"; +type CornerXY = { x: "left" | "right"; y: "top" | "bottom" }; + +export type MaskHandleId = + | { kind: "position" } + | { kind: "rotation" } + | { kind: "feather" } + | { kind: "scale" } + | { kind: "edge"; side: Side } + | { kind: "corner"; corner: CornerXY } + | { kind: "anchor"; pointId: string } + | { kind: "segment"; index: number }; + +export function maskHandleIdKey({ id }: { id: MaskHandleId }): string { + switch (id.kind) { + case "position": + case "rotation": + case "feather": + case "scale": + return id.kind; + case "edge": + return id.side; + case "corner": + return `${id.corner.y}-${id.corner.x}`; + case "anchor": + return `point:${id.pointId}:anchor`; + case "segment": + return `segment:${id.index}`; + } +} export interface MaskHandlePosition { - id: string; + id: MaskHandleId; x: number; y: number; cursor: string; @@ -187,7 +238,7 @@ export interface MaskLineOverlay { start: { x: number; y: number }; end: { x: number; y: number }; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; } export interface MaskRectOverlay { @@ -199,7 +250,7 @@ export interface MaskRectOverlay { rotation: number; dashed?: boolean; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; } export interface MaskShapeOverlay { @@ -211,7 +262,7 @@ export interface MaskShapeOverlay { rotation: number; pathData: string; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; } export interface MaskCanvasPathOverlay { @@ -220,7 +271,7 @@ export interface MaskCanvasPathOverlay { pathData: string; coordinateSpace?: "canvas" | "overlay"; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; strokeWidth?: number; strokeOpacity?: number; } @@ -238,7 +289,7 @@ export interface MaskDefaultContext { export interface MaskParamUpdateArgs< TParams extends BaseMaskParams = BaseMaskParams, > { - handleId: string; + handleId: MaskHandleId; startParams: TParams; deltaX: number; deltaY: number; @@ -249,7 +300,7 @@ export interface MaskParamUpdateArgs< } export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> { - handleId: string; + handleId: MaskHandleId; startParams: TParams; proposedParams: TParams; bounds: ElementBounds; @@ -282,17 +333,17 @@ export interface MaskInteractionDefinition< snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>; } -export interface MaskDefinition< - TParams extends BaseMaskParams = BaseMaskParams, -> { - type: MaskType; +export interface MaskDefinition<TType extends MaskType = MaskType> { + type: TType; name: string; features: MaskFeatures; - params: ParamDefinition<keyof TParams & string>[]; - renderer: MaskRenderer; - interaction: MaskInteractionDefinition<TParams>; + params: ParamDefinition<keyof MaskParamsByType<TType> & string>[]; + renderer: MaskRenderer<MaskParamsByType<TType>>; + interaction: MaskInteractionDefinition<MaskParamsByType<TType>>; /** When defined and returning false, the mask is not applied and the element renders fully visible. */ - isActive?: (params: TParams) => boolean; - buildDefault(context: MaskDefaultContext): Omit<Mask, "id">; - computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>; + isActive?(params: MaskParamsByType<TType>): boolean; + buildDefault(context: MaskDefaultContext): Omit<MaskByType<TType>, "id">; + computeParamUpdate( + args: MaskParamUpdateArgs<MaskParamsByType<TType>>, + ): Partial<MaskParamsByType<TType>>; } diff --git a/apps/web/src/masks/use-mask-handles.ts b/apps/web/src/masks/use-mask-handles.ts index b81de4f4..f889e029 100644 --- a/apps/web/src/masks/use-mask-handles.ts +++ b/apps/web/src/masks/use-mask-handles.ts @@ -1,13 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { masksRegistry } from "@/masks"; -import { - parseCustomMaskHandleId, - parseCustomMaskSegmentHandleId, -} from "@/masks/custom-path"; -import { appendPointToCustomMask } from "@/masks/definitions/custom"; +import { getMaskDefinition } from "@/masks"; +import { appendPointToFreeformPathMask } from "@/masks/freeform/definition"; import { getVisibleElementsWithBounds, type ElementBounds, @@ -17,14 +13,15 @@ import { type SnapLine, } from "@/preview/preview-snap"; import type { SelectedMaskPointSelection } from "@/selection/editor-selection"; -import type { Mask, MaskInteractionResult } from "@/masks/types"; +import type { Mask, MaskHandleId, MaskInteractionResult } from "@/masks/types"; import type { MaskableElement } from "@/timeline"; +import { isMaskableElement } from "@/timeline/element-utils"; import { registerCanceller } from "@/editor/cancel-interaction"; interface DragState { trackId: string; elementId: string; - handleId: string; + handleId: MaskHandleId; startCanvasX: number; startCanvasY: number; startParams: Mask["params"]; @@ -99,12 +96,12 @@ export function useMaskHandles({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const viewport = usePreviewViewport(); - const [activeHandleId, setActiveHandleId] = useState<string | null>(null); + const [activeHandleId, setActiveHandleId] = useState<MaskHandleId | null>(null); const dragStateRef = useRef<DragState | null>(null); const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>( null, ); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + const captureRef = useRef<{ element: Element; pointerId: number } | null>( null, ); @@ -137,7 +134,8 @@ export function useMaskHandles({ item.trackId === sel.trackId && item.elementId === sel.elementId, ); if (!entry) return null; - const element = entry.element as MaskableElement; + if (!isMaskableElement(entry.element)) return null; + const element = entry.element; const masks = element.masks ?? []; if (masks.length === 0) return null; const activeMaskId = masks.some((mask) => @@ -160,7 +158,7 @@ export function useMaskHandles({ const { handles: baseHandlePositions, overlays }: MaskInteractionResult = selectedWithMask ? (() => { - const def = masksRegistry.get(selectedWithMask.mask.type); + const def = getMaskDefinition(selectedWithMask.mask.type); const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); const displayScale = (scaleX + scaleY) / 2; return def.interaction.getInteraction({ @@ -186,30 +184,29 @@ export function useMaskHandles({ ); const handlePositions = baseHandlePositions.map((handle) => { - const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id }); - if (!parsedHandle || parsedHandle.part !== "anchor") { + if (handle.id.kind !== "anchor") { return handle; } return { ...handle, - isSelected: selectedPointIds.has(parsedHandle.pointId), + isSelected: selectedPointIds.has(handle.id.pointId), }; }); - const customMaskPointIds = - selectedWithMask?.mask.type === "custom" - ? handlePositions - .filter((h) => h.kind === "point") - .map((h) => { - const parsedHandle = parseCustomMaskHandleId({ handleId: h.id }); - return parsedHandle?.part === "anchor" - ? parsedHandle.pointId - : null; - }) - .filter((id): id is string => id !== null) - : []; - const isCreatingCustomMask = - selectedWithMask?.mask.type === "custom" && + const customMaskPointIds = useMemo( + () => + selectedWithMask?.mask.type === "freeform" + ? handlePositions + .filter((h) => h.kind === "point") + .map((h) => { + return h.id.kind === "anchor" ? h.id.pointId : null; + }) + .filter((id): id is string => id !== null) + : [], + [handlePositions, selectedWithMask?.mask.type], + ); + const isCreatingFreeformPathMask = + selectedWithMask?.mask.type === "freeform" && (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0); useEffect(() => { @@ -218,7 +215,7 @@ export function useMaskHandles({ } if ( !selectedWithMask || - selectedWithMask.mask.type !== "custom" || + selectedWithMask.mask.type !== "freeform" || !isMaskSelectionForElement({ trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, @@ -257,7 +254,7 @@ export function useMaskHandles({ selectedWithMask, ]); - const updateCustomMaskPointSelection = useCallback( + const updateFreeformPathMaskPointSelection = useCallback( ({ pointId, toggleSelection, @@ -265,7 +262,7 @@ export function useMaskHandles({ pointId: string; toggleSelection: boolean; }) => { - if (!selectedWithMask || selectedWithMask.mask.type !== "custom") { + if (!selectedWithMask || selectedWithMask.mask.type !== "freeform") { return; } @@ -339,25 +336,32 @@ export function useMaskHandles({ ]); const handlePointerDown = useCallback( - ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => { + ({ + event, + handleId, + }: { + event: React.PointerEvent; + handleId: MaskHandleId; + }) => { if (!selectedWithMask) return; if (event.button !== 0) return; event.stopPropagation(); - const parsedHandle = - selectedWithMask.mask.type === "custom" - ? parseCustomMaskHandleId({ handleId }) + const anchorHandle = + selectedWithMask.mask.type === "freeform" && handleId.kind === "anchor" + ? handleId : null; - const parsedSegmentHandle = - selectedWithMask.mask.type === "custom" - ? parseCustomMaskSegmentHandleId({ handleId }) + const segmentHandle = + selectedWithMask.mask.type === "freeform" && handleId.kind === "segment" + ? handleId : null; - if (isCreatingCustomMask) { + if (isCreatingFreeformPathMask) { const firstPointId = customMaskPointIds[0]; if ( firstPointId && - handleId === `point:${firstPointId}:anchor` && + handleId.kind === "anchor" && + handleId.pointId === firstPointId && customMaskPointIds.length >= 3 && - selectedWithMask.mask.type === "custom" + selectedWithMask.mask.type === "freeform" ) { const updatedMask = withUpdatedMaskParams({ mask: selectedWithMask.mask, @@ -390,13 +394,13 @@ export function useMaskHandles({ }); if (!pos) return; - if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") { + if (segmentHandle && selectedWithMask.mask.type === "freeform") { setActiveHandleId(handleId); pendingSegmentInsertRef.current = { trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, maskId: selectedWithMask.mask.id, - segmentIndex: parsedSegmentHandle.segmentIndex, + segmentIndex: segmentHandle.index, startClientX: event.clientX, startClientY: event.clientY, startCanvasX: pos.x, @@ -404,7 +408,7 @@ export function useMaskHandles({ startParams: { ...selectedWithMask.mask.params }, bounds: selectedWithMask.bounds, }; - const captureTarget = event.currentTarget as HTMLElement; + const captureTarget = event.currentTarget; captureTarget.setPointerCapture(event.pointerId); captureRef.current = { element: captureTarget, @@ -413,15 +417,15 @@ export function useMaskHandles({ return; } - if (parsedHandle?.part === "anchor") { - updateCustomMaskPointSelection({ - pointId: parsedHandle.pointId, + if (anchorHandle) { + updateFreeformPathMaskPointSelection({ + pointId: anchorHandle.pointId, toggleSelection: event.shiftKey, }); if (event.shiftKey) { return; } - } else if (selectedWithMask.mask.type === "custom") { + } else if (selectedWithMask.mask.type === "freeform") { editor.selection.clearMaskPointSelection(); } @@ -434,7 +438,7 @@ export function useMaskHandles({ startParams: { ...selectedWithMask.mask.params }, }; setActiveHandleId(handleId); - const captureTarget = event.currentTarget as HTMLElement; + const captureTarget = event.currentTarget; captureTarget.setPointerCapture(event.pointerId); captureRef.current = { element: captureTarget, @@ -445,22 +449,22 @@ export function useMaskHandles({ customMaskPointIds, editor.selection, editor.timeline, - isCreatingCustomMask, + isCreatingFreeformPathMask, selectedWithMask, - updateCustomMaskPointSelection, + updateFreeformPathMaskPointSelection, viewport, ], ); const handleCanvasPointerDown = useCallback( ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithMask || !isCreatingCustomMask) { + if (!selectedWithMask || !isCreatingFreeformPathMask) { return; } if (event.button !== 0) { return; } - if (selectedWithMask.mask.type !== "custom") { + if (selectedWithMask.mask.type !== "freeform") { return; } @@ -473,7 +477,7 @@ export function useMaskHandles({ return; } - const nextParams = appendPointToCustomMask({ + const nextParams = appendPointToFreeformPathMask({ params: selectedWithMask.mask.params, canvasPoint: pos, bounds: selectedWithMask.bounds, @@ -498,7 +502,7 @@ export function useMaskHandles({ ], }); }, - [editor.timeline, isCreatingCustomMask, selectedWithMask, viewport], + [editor.timeline, isCreatingFreeformPathMask, selectedWithMask, viewport], ); const handlePointerMove = useCallback( @@ -513,13 +517,13 @@ export function useMaskHandles({ dragStateRef.current = { trackId: pendingSegmentInsert.trackId, elementId: pendingSegmentInsert.elementId, - handleId: "position", + handleId: { kind: "position" }, startCanvasX: pendingSegmentInsert.startCanvasX, startCanvasY: pendingSegmentInsert.startCanvasY, startParams: pendingSegmentInsert.startParams, }; pendingSegmentInsertRef.current = null; - setActiveHandleId("position"); + setActiveHandleId({ kind: "position" }); } } @@ -534,7 +538,7 @@ export function useMaskHandles({ const deltaX = pos.x - drag.startCanvasX; const deltaY = pos.y - drag.startCanvasY; - const def = masksRegistry.get(selectedWithMask.mask.type); + const def = getMaskDefinition(selectedWithMask.mask.type); const rawParams = def.computeParamUpdate({ handleId: drag.handleId, @@ -596,7 +600,7 @@ export function useMaskHandles({ const handlePointerUp = useCallback(() => { const pendingSegmentInsert = pendingSegmentInsertRef.current; if (pendingSegmentInsert && !dragStateRef.current) { - editor.timeline.insertCustomMaskPoint({ + editor.timeline.insertFreeformPathMaskPoint({ trackId: pendingSegmentInsert.trackId, elementId: pendingSegmentInsert.elementId, maskId: pendingSegmentInsert.maskId, @@ -623,7 +627,7 @@ export function useMaskHandles({ selectedWithMask, handlePositions, overlays, - isCreatingCustomMask, + isCreatingFreeformPathMask, handleCanvasPointerDown, activeHandleId, handlePointerDown, diff --git a/apps/web/src/media/audio.ts b/apps/web/src/media/audio.ts index 42756d49..7abaf90e 100644 --- a/apps/web/src/media/audio.ts +++ b/apps/web/src/media/audio.ts @@ -11,6 +11,7 @@ import { applyAudioMasteringToBuffer } from "@/media/audio-mastering"; import type { AudioCapableElement } from "@/timeline/audio-state"; import { hasAnimatedVolume, + isElementMuted, resolveEffectiveAudioGain, } from "@/timeline/audio-state"; import { doesElementHaveEnabledAudio } from "@/timeline/audio-separation"; @@ -128,7 +129,7 @@ export function timelineHasAudio({ mediaAssets: MediaAsset[]; }): boolean { return collectAudibleCandidates({ tracks, mediaAssets }).some( - ({ element }) => element.muted !== true, + ({ element }) => !isElementMuted({ element }), ); } @@ -168,7 +169,7 @@ export async function collectAudioElements({ trackMuted: false, localTime: 0, }), - muted: element.muted === true, + muted: isElementMuted({ element }), retime: element.retime, }; }), @@ -197,7 +198,7 @@ export async function collectAudioElements({ trackMuted: false, localTime: 0, }), - muted: element.muted ?? false, + muted: isElementMuted({ element }), retime: element.retime, }; }), @@ -501,7 +502,7 @@ export async function collectAudioMixSources({ for (const element of track.elements) { if (!canElementHaveAudio(element)) continue; - if (element.muted === true) continue; + if (isElementMuted({ element })) continue; const mediaAsset = hasMediaId(element) ? (mediaMap.get(element.mediaId) ?? null) : null; @@ -570,9 +571,7 @@ export async function collectAudioClips({ : null; if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue; - const isElementMuted = - "muted" in element ? (element.muted ?? false) : false; - const muted = isTrackMuted || isElementMuted; + const muted = isTrackMuted || isElementMuted({ element }); const volume = resolveEffectiveAudioGain({ element, trackMuted: isTrackMuted, diff --git a/apps/web/src/media/use-paste-media.ts b/apps/web/src/media/use-paste-media.ts index 3055920e..7dbd4078 100644 --- a/apps/web/src/media/use-paste-media.ts +++ b/apps/web/src/media/use-paste-media.ts @@ -7,7 +7,7 @@ import { AddMediaAssetCommand } from "@/commands/media"; import { InsertElementCommand } from "@/commands/timeline"; import { BatchCommand } from "@/commands"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds } from "@/wasm"; import { isTypableDOMElement } from "@/utils/browser"; import type { MediaType } from "@/media/types"; @@ -68,14 +68,14 @@ export function usePasteMedia() { const startTime = editor.playback.getCurrentTime(); for (const asset of processedAssets) { - const addMediaCmd = new AddMediaAssetCommand( - activeProject.metadata.id, + const addMediaCmd = new AddMediaAssetCommand({ + projectId: activeProject.metadata.id, asset, - ); + }); const assetId = addMediaCmd.getAssetId(); const duration = asset.duration != null - ? Math.round(asset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: asset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const trackType = asset.type === "audio" ? "audio" : "video"; diff --git a/apps/web/src/params/__tests__/channel-layout.test.ts b/apps/web/src/params/__tests__/channel-layout.test.ts new file mode 100644 index 00000000..e058418d --- /dev/null +++ b/apps/web/src/params/__tests__/channel-layout.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { formatLinearRgba } from "@/params"; + +describe("channel layout", () => { + test("formats linear animated colors as hex", () => { + expect(formatLinearRgba({ color: { r: 1, g: 0, b: 0, a: 1 } })).toBe( + "#ff0000", + ); + }); +}); diff --git a/apps/web/src/params/index.ts b/apps/web/src/params/index.ts index 633bfbd0..fa2550f8 100644 --- a/apps/web/src/params/index.ts +++ b/apps/web/src/params/index.ts @@ -1,50 +1,342 @@ -export type ParamValues = Record<string, number | string | boolean>;
-
-export type ParamGroup = "stroke";
-
-interface BaseParamDefinition<TKey extends string = string> {
- key: TKey;
- label: string;
- group?: ParamGroup;
-}
-
-export interface NumberParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "number";
- default: number;
- min: number;
- max?: number;
- step: number;
- /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
- displayMultiplier?: number;
- /** Show as percentage of max. min/max/step/default stay in stored space. */
- unit?: "percent";
- /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
- shortLabel?: string;
-}
-
-export interface BooleanParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "boolean";
- default: boolean;
-}
-
-export interface ColorParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "color";
- default: string;
-}
-
-export interface SelectParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "select";
- default: string;
- options: Array<{ value: string; label: string }>;
-}
-
-export type ParamDefinition<TKey extends string = string> =
- | NumberParamDefinition<TKey>
- | BooleanParamDefinition<TKey>
- | ColorParamDefinition<TKey>
- | SelectParamDefinition<TKey>;
-
+import { converter, formatHex, formatHex8, parse } from "culori"; +import { clamp, snapToStep } from "@/utils/math"; + +export type ParamValue = number | string | boolean; +export type ParamValues = Record<string, ParamValue>; + +export type ParamGroup = "stroke"; + +export type ChannelValueKind = "scalar" | "discrete"; +export type ChannelEasingMode = "independent" | "shared"; + +export interface LinearRgba { + r: number; + g: number; + b: number; + a: number; +} + +export interface ChannelComponentDefinition<TKey extends string = string> { + key: TKey; + valueKind: ChannelValueKind; + defaultInterpolation: "linear" | "hold"; +} + +export interface LeafChannelLayout<TValue extends ParamValue = ParamValue> { + kind: "leaf"; + component: ChannelComponentDefinition<"value">; + easingMode: "independent"; + decompose: (value: TValue) => { value: TValue }; + compose: (components: { value?: TValue }) => TValue | null; +} + +export interface CompositeChannelLayout< + TValue extends ParamValue = ParamValue, + TComponents extends object = Record<string, ParamValue>, +> { + kind: "composite"; + components: Array<ChannelComponentDefinition<keyof TComponents & string>>; + easingMode: ChannelEasingMode; + decompose: (value: TValue) => TComponents | null; + compose: (components: Partial<TComponents>) => TValue | null; +} + +export type ChannelLayout< + TValue extends ParamValue = ParamValue, + TComponents extends object = Record<string, ParamValue>, +> = LeafChannelLayout<TValue> | CompositeChannelLayout<TValue, TComponents>; + +export type ParamChannelLayout = + | LeafChannelLayout<number> + | LeafChannelLayout<boolean> + | LeafChannelLayout<string> + | CompositeChannelLayout<string, LinearRgba>; + +const toRgb = converter("rgb"); + +function srgbToLinear({ value }: { value: number }): number { + return value <= 0.04045 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); +} + +function linearToSrgb({ value }: { value: number }): number { + const clamped = clamp({ value, min: 0, max: 1 }); + return clamped <= 0.0031308 + ? clamped * 12.92 + : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; +} + +export function parseColorToLinearRgba({ + color, +}: { + color: string; +}): LinearRgba | null { + const parsed = parse(color); + const rgb = parsed ? toRgb(parsed) : null; + if (!rgb) { + return null; + } + + return { + r: srgbToLinear({ value: rgb.r ?? 0 }), + g: srgbToLinear({ value: rgb.g ?? 0 }), + b: srgbToLinear({ value: rgb.b ?? 0 }), + a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }), + }; +} + +export function formatLinearRgba({ + color, +}: { + color: LinearRgba; +}): string { + const rgb: { + mode: "rgb"; + r: number; + g: number; + b: number; + alpha: number; + } = { + mode: "rgb", + r: linearToSrgb({ value: color.r }), + g: linearToSrgb({ value: color.g }), + b: linearToSrgb({ value: color.b }), + alpha: clamp({ value: color.a, min: 0, max: 1 }), + }; + return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); +} + +function createLeafChannelLayout<TValue extends ParamValue>({ + valueKind, + defaultInterpolation, +}: { + valueKind: ChannelValueKind; + defaultInterpolation: "linear" | "hold"; +}): LeafChannelLayout<TValue> { + return { + kind: "leaf", + component: { + key: "value", + valueKind, + defaultInterpolation, + }, + easingMode: "independent", + decompose: (value) => ({ value }), + compose: ({ value }) => value ?? null, + }; +} + +export const NUMBER_CHANNEL_LAYOUT: LeafChannelLayout<number> = + createLeafChannelLayout<number>({ + valueKind: "scalar", + defaultInterpolation: "linear", + }); + +export const BOOLEAN_CHANNEL_LAYOUT: LeafChannelLayout<boolean> = + createLeafChannelLayout<boolean>({ + valueKind: "discrete", + defaultInterpolation: "hold", + }); + +export const STRING_CHANNEL_LAYOUT: LeafChannelLayout<string> = + createLeafChannelLayout<string>({ + valueKind: "discrete", + defaultInterpolation: "hold", + }); + +const colorComponent = ( + key: keyof LinearRgba, +): ChannelComponentDefinition<keyof LinearRgba> => ({ + key, + valueKind: "scalar", + defaultInterpolation: "linear", +}); + +export const COLOR_CHANNEL_LAYOUT: CompositeChannelLayout<string, LinearRgba> = { + kind: "composite", + components: [ + colorComponent("r"), + colorComponent("g"), + colorComponent("b"), + colorComponent("a"), + ], + easingMode: "shared", + decompose: (value) => parseColorToLinearRgba({ color: value }), + compose: ({ r, g, b, a }) => + typeof r === "number" && + typeof g === "number" && + typeof b === "number" && + typeof a === "number" + ? formatLinearRgba({ color: { r, g, b, a } }) + : null, +}; + +interface BaseParamDefinition<TKey extends string = string> { + key: TKey; + label: string; + group?: ParamGroup; + keyframable?: boolean; + dependencies?: Array<{ param: string; equals: ParamValue }>; +} + +export interface NumberParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "number"; + default: number; + channels?: LeafChannelLayout<number>; + min: number; + max?: number; + step: number; + /** When set, min/max/step are in display space. display = stored * displayMultiplier. */ + displayMultiplier?: number; + /** Show as percentage of max. min/max/step/default stay in stored space. */ + unit?: "percent"; + /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */ + shortLabel?: string; +} + +export interface BooleanParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "boolean"; + default: boolean; + channels?: LeafChannelLayout<boolean>; +} + +export interface ColorParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "color"; + default: string; + channels?: ChannelLayout<string, LinearRgba>; +} + +export interface SelectParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "select"; + default: string; + channels?: LeafChannelLayout<string>; + options: Array<{ value: string; label: string }>; +} + +export interface TextParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "text"; + default: string; + channels?: LeafChannelLayout<string>; +} + +export interface FontParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "font"; + default: string; + channels?: LeafChannelLayout<string>; +} + +export type ParamDefinition<TKey extends string = string> = + | NumberParamDefinition<TKey> + | BooleanParamDefinition<TKey> + | ColorParamDefinition<TKey> + | SelectParamDefinition<TKey> + | TextParamDefinition<TKey> + | FontParamDefinition<TKey>; + +export function getParamChannelLayout({ + param, +}: { + param: ParamDefinition; +}): ParamChannelLayout { + switch (param.type) { + case "number": + return param.channels ?? NUMBER_CHANNEL_LAYOUT; + case "boolean": + return param.channels ?? BOOLEAN_CHANNEL_LAYOUT; + case "color": + return param.channels ?? COLOR_CHANNEL_LAYOUT; + case "select": + case "text": + case "font": + return param.channels ?? STRING_CHANNEL_LAYOUT; + default: { + const exhaustive: never = param; + return exhaustive; + } + } +} + +export function getParamValueKind({ + param, +}: { + param: ParamDefinition; +}): "number" | "color" | "discrete" { + const layout = getParamChannelLayout({ param }); + if (layout.kind === "composite") { + return "color"; + } + if (layout.component.valueKind === "scalar") { + return "number"; + } + return "discrete"; +} + +export function getParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): "linear" | "hold" { + const layout = getParamChannelLayout({ param }); + if (layout.kind === "leaf") { + return layout.component.defaultInterpolation; + } + return layout.components[0]?.defaultInterpolation ?? "linear"; +} + +export function getParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): { min?: number; max?: number; step?: number } | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + min: param.min, + max: param.max, + step: param.step, + }; +} + +export function coerceParamValue({ + param, + value, +}: { + param: ParamDefinition; + value: unknown; +}): ParamValue | null { + switch (param.type) { + case "number": { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(param.min, steppedValue)); + } + case "boolean": + return typeof value === "boolean" ? value : null; + case "color": + case "text": + case "font": + return typeof value === "string" ? value : null; + case "select": + return typeof value === "string" && + param.options.some((option) => option.value === value) + ? value + : null; + default: { + const exhaustive: never = param; + return exhaustive; + } + } +} diff --git a/apps/web/src/params/registry.ts b/apps/web/src/params/registry.ts index d9fcc266..9e363c88 100644 --- a/apps/web/src/params/registry.ts +++ b/apps/web/src/params/registry.ts @@ -1,40 +1,438 @@ -import type { ParamDefinition, ParamValues } from "@/params";
-
-export function buildDefaultParamValues(
- params: ParamDefinition[],
-): ParamValues {
- const values: ParamValues = {};
- for (const param of params) {
- values[param.key] = param.default;
- }
- return values;
-}
-
-export class DefinitionRegistry<TKey extends string, TDefinition> {
- private definitions = new Map<TKey, TDefinition>();
- private entityName: string;
-
- constructor(entityName: string) {
- this.entityName = entityName;
- }
-
- register(key: TKey, definition: TDefinition): void {
- this.definitions.set(key, definition);
- }
-
- has(key: TKey): boolean {
- return this.definitions.has(key);
- }
-
- get(key: TKey): TDefinition {
- const def = this.definitions.get(key);
- if (!def) {
- throw new Error(`Unknown ${this.entityName}: ${key}`);
- }
- return def;
- }
-
- getAll(): TDefinition[] {
- return Array.from(this.definitions.values());
- }
-}
+import type { + ParamDefinition, + ParamValue, + ParamValues, +} from "@/params"; +import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; +import type { BlendMode } from "@/rendering"; +import type { + ElementType, + TimelineElement, +} from "@/timeline"; +import { DEFAULTS } from "@/timeline/defaults"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, +} from "@/text/background"; + +export type ElementParamDefinition<TKey extends string = string> = + ParamDefinition<TKey> & { + read?: ({ element }: { element: TimelineElement }) => ParamValue | null; + write?: ({ + element, + value, + }: { + element: TimelineElement; + value: ParamValue; + }) => TimelineElement; + }; + +export function buildDefaultParamValues( + params: readonly ParamDefinition[], +): ParamValues { + const values: ParamValues = {}; + for (const param of params) { + values[param.key] = param.default; + } + return values; +} + +export class DefinitionRegistry<TKey extends string, TDefinition> { + private definitions = new Map<TKey, TDefinition>(); + private entityName: string; + + constructor(entityName: string) { + this.entityName = entityName; + } + + register({ + key, + definition, + }: { + key: TKey; + definition: TDefinition; + }): void { + this.definitions.set(key, definition); + } + + has(key: TKey): boolean { + return this.definitions.has(key); + } + + get(key: TKey): TDefinition { + const def = this.definitions.get(key); + if (!def) { + throw new Error(`Unknown ${this.entityName}: ${key}`); + } + return def; + } + + getAll(): TDefinition[] { + return Array.from(this.definitions.values()); + } +} + +const BLEND_MODE_OPTIONS: Array<{ value: BlendMode; label: string }> = [ + { value: "normal", label: "Normal" }, + { value: "darken", label: "Darken" }, + { value: "multiply", label: "Multiply" }, + { value: "color-burn", label: "Color Burn" }, + { value: "lighten", label: "Lighten" }, + { value: "screen", label: "Screen" }, + { value: "plus-lighter", label: "Plus Lighter" }, + { value: "color-dodge", label: "Color Dodge" }, + { value: "overlay", label: "Overlay" }, + { value: "soft-light", label: "Soft Light" }, + { value: "hard-light", label: "Hard Light" }, + { value: "difference", label: "Difference" }, + { value: "exclusion", label: "Exclusion" }, + { value: "hue", label: "Hue" }, + { value: "saturation", label: "Saturation" }, + { value: "color", label: "Color" }, + { value: "luminosity", label: "Luminosity" }, +]; + +const visualElementParams: ElementParamDefinition[] = [ + { + key: "transform.positionX", + label: "Position X", + type: "number", + default: DEFAULTS.element.transform.position.x, + min: -100_000, + step: 1, + }, + { + key: "transform.positionY", + label: "Position Y", + type: "number", + default: DEFAULTS.element.transform.position.y, + min: -100_000, + step: 1, + }, + { + key: "transform.scaleX", + label: "Scale X", + type: "number", + default: DEFAULTS.element.transform.scaleX, + min: MIN_TRANSFORM_SCALE, + step: 0.01, + }, + { + key: "transform.scaleY", + label: "Scale Y", + type: "number", + default: DEFAULTS.element.transform.scaleY, + min: MIN_TRANSFORM_SCALE, + step: 0.01, + }, + { + key: "transform.rotate", + label: "Rotate", + type: "number", + default: DEFAULTS.element.transform.rotate, + min: -360, + max: 360, + step: 1, + }, + { + key: "opacity", + label: "Opacity", + type: "number", + default: DEFAULTS.element.opacity, + min: 0, + max: 1, + step: 0.01, + }, + { + key: "blendMode", + label: "Blend Mode", + type: "select", + default: DEFAULTS.element.blendMode, + keyframable: false, + options: BLEND_MODE_OPTIONS, + }, +]; + +const audioElementParams: ElementParamDefinition[] = [ + { + key: "volume", + label: "Volume", + type: "number", + default: DEFAULTS.element.volume, + min: VOLUME_DB_MIN, + max: VOLUME_DB_MAX, + step: 0.01, + }, + { + key: "muted", + label: "Muted", + type: "boolean", + default: false, + keyframable: false, + }, +]; + +const textElementParams: ElementParamDefinition[] = [ + { + key: "content", + label: "Content", + type: "text", + default: "Default text", + keyframable: false, + }, + { + key: "fontFamily", + label: "Font Family", + type: "font", + default: "Arial", + keyframable: false, + }, + { + key: "fontSize", + label: "Font Size", + type: "number", + default: 15, + min: 1, + step: 1, + }, + { + key: "color", + label: "Color", + type: "color", + default: "#ffffff", + }, + { + key: "textAlign", + label: "Text Align", + type: "select", + default: "center", + keyframable: false, + options: [ + { value: "left", label: "Left" }, + { value: "center", label: "Center" }, + { value: "right", label: "Right" }, + ], + }, + { + key: "fontWeight", + label: "Font Weight", + type: "select", + default: "normal", + keyframable: false, + options: [ + { value: "normal", label: "Normal" }, + { value: "bold", label: "Bold" }, + ], + }, + { + key: "fontStyle", + label: "Font Style", + type: "select", + default: "normal", + keyframable: false, + options: [ + { value: "normal", label: "Normal" }, + { value: "italic", label: "Italic" }, + ], + }, + { + key: "textDecoration", + label: "Text Decoration", + type: "select", + default: "none", + keyframable: false, + options: [ + { value: "none", label: "None" }, + { value: "underline", label: "Underline" }, + { value: "line-through", label: "Line Through" }, + ], + }, + { + key: "letterSpacing", + label: "Letter Spacing", + type: "number", + default: DEFAULTS.text.letterSpacing, + min: -100, + step: 0.1, + }, + { + key: "lineHeight", + label: "Line Height", + type: "number", + default: DEFAULTS.text.lineHeight, + min: 0.1, + step: 0.1, + }, + { + key: "background.enabled", + label: "Background Enabled", + type: "boolean", + default: DEFAULTS.text.background.enabled, + keyframable: false, + }, + { + key: "background.color", + label: "Background Color", + type: "color", + default: DEFAULTS.text.background.color, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.cornerRadius", + label: "Background Radius", + type: "number", + default: DEFAULTS.text.background.cornerRadius, + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.paddingX", + label: "Background Padding X", + type: "number", + default: DEFAULTS.text.background.paddingX, + min: 0, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.paddingY", + label: "Background Padding Y", + type: "number", + default: DEFAULTS.text.background.paddingY, + min: 0, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.offsetX", + label: "Background Offset X", + type: "number", + default: DEFAULTS.text.background.offsetX, + min: -100_000, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.offsetY", + label: "Background Offset Y", + type: "number", + default: DEFAULTS.text.background.offsetY, + min: -100_000, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, +]; + +export const elementParamRegistry = new DefinitionRegistry< + ElementType, + readonly ElementParamDefinition[] +>("element params"); + +elementParamRegistry.register({ + key: "video", + definition: [...visualElementParams, ...audioElementParams], +}); +elementParamRegistry.register({ key: "image", definition: visualElementParams }); +elementParamRegistry.register({ + key: "text", + definition: [...textElementParams, ...visualElementParams], +}); +elementParamRegistry.register({ + key: "sticker", + definition: visualElementParams, +}); +elementParamRegistry.register({ + key: "graphic", + definition: visualElementParams, +}); +elementParamRegistry.register({ key: "audio", definition: audioElementParams }); +elementParamRegistry.register({ key: "effect", definition: [] }); + +export function getElementParams({ + element, +}: { + element: TimelineElement; +}): readonly ElementParamDefinition[] { + return elementParamRegistry.has(element.type) + ? elementParamRegistry.get(element.type) + : []; +} + +export function getBuiltInElementParams({ + type, +}: { + type: ElementType; +}): readonly ElementParamDefinition[] { + return elementParamRegistry.has(type) ? elementParamRegistry.get(type) : []; +} + +export function getElementParam({ + element, + key, +}: { + element: TimelineElement; + key: string; +}): ElementParamDefinition | null { + return ( + getElementParams({ element }).find((param) => param.key === key) ?? null + ); +} + +export function readElementParamValue({ + element, + param, +}: { + element: TimelineElement; + param: ElementParamDefinition; +}): ParamValue | null { + if (param.read) { + return param.read({ element }); + } + if ("params" in element) { + return element.params[param.key] ?? param.default; + } + return null; +} + +export function writeElementParamValue({ + element, + param, + value, +}: { + element: TimelineElement; + param: ElementParamDefinition; + value: ParamValue; +}): TimelineElement { + if (param.write) { + return param.write({ element, value }); + } + if ("params" in element) { + return { + ...element, + params: { + ...element.params, + [param.key]: value, + }, + }; + } + return element; +} + +export function buildElementParamValues({ + element, +}: { + element: TimelineElement; +}): ParamValues { + const values: ParamValues = {}; + for (const param of getElementParams({ element })) { + const value = readElementParamValue({ element, param }); + if (value !== null) { + values[param.key] = value; + } + } + return values; +} + diff --git a/apps/web/src/preview/components/context-menu.tsx b/apps/web/src/preview/components/context-menu.tsx index fca55d71..a0f1bc5c 100644 --- a/apps/web/src/preview/components/context-menu.tsx +++ b/apps/web/src/preview/components/context-menu.tsx @@ -13,12 +13,12 @@ import { toast } from "sonner"; export function PreviewContextMenu({ onToggleFullscreen, - containerRef, + container, overlayControls, onOverlayVisibilityChange, }: { onToggleFullscreen: () => void; - containerRef: React.RefObject<HTMLElement | null>; + container: HTMLElement | null; overlayControls: PreviewOverlayControl[]; onOverlayVisibilityChange: (params: { overlayId: string; @@ -51,7 +51,7 @@ export function PreviewContextMenu({ }; return ( - <ContextMenuContent className="w-56" container={containerRef.current}> + <ContextMenuContent className="w-56" container={container}> <ContextMenuItem onClick={viewport.fitToScreen} inset> Fit to screen </ContextMenuItem> diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx index e8f5c572..894eec78 100644 --- a/apps/web/src/preview/components/index.tsx +++ b/apps/web/src/preview/components/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useDeepCompareEffect from "use-deep-compare-effect"; import { useEditor } from "@/editor/use-editor"; import { useRafLoop } from "@/hooks/use-raf-loop"; @@ -68,15 +68,20 @@ export function PreviewPanel({ }) => void; }) { const containerRef = useRef<HTMLDivElement>(null); + const [container, setContainer] = useState<HTMLDivElement | null>(null); const { toggleFullscreen } = useFullscreen({ containerRef }); + const handleContainerRef = useCallback((node: HTMLDivElement | null) => { + containerRef.current = node; + setContainer(node); + }, []); return ( <div - ref={containerRef} + ref={handleContainerRef} className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border" > <PreviewCanvas - containerRef={containerRef} + container={container} onToggleFullscreen={toggleFullscreen} overlayControls={overlayControls} overlayInstances={overlayInstances} @@ -117,13 +122,13 @@ function RenderTreeController() { } function PreviewCanvas({ - containerRef, + container, onToggleFullscreen, overlayControls, overlayInstances, onOverlayVisibilityChange, }: { - containerRef: React.RefObject<HTMLElement | null>; + container: HTMLElement | null; onToggleFullscreen: () => void; overlayControls: PreviewOverlayControl[]; overlayInstances: PreviewOverlayInstance[]; @@ -149,6 +154,7 @@ function PreviewCanvas({ viewportRef, viewportWidth: viewportSize.width, }); + const { canPan, panByScreenDelta, scaleZoom } = viewport; const renderer = useMemo(() => { return new CanvasRenderer({ @@ -240,7 +246,7 @@ function PreviewCanvas({ Math.min(Math.abs(pendingZoomDelta), 30); const zoomFactor = Math.exp(-cappedDelta / 300); - viewport.scaleZoom({ factor: zoomFactor }); + scaleZoom({ factor: zoomFactor }); pendingZoomDelta = 0; zoomRafId = null; }); @@ -249,7 +255,7 @@ function PreviewCanvas({ return; } - if (!viewport.canPan) { + if (!canPan) { return; } @@ -263,7 +269,7 @@ function PreviewCanvas({ if (panRafId === null) { panRafId = requestAnimationFrame(() => { - viewport.panByScreenDelta({ + panByScreenDelta({ deltaX: pendingPanDeltaX, deltaY: pendingPanDeltaY, }); @@ -290,7 +296,7 @@ function PreviewCanvas({ cancelAnimationFrame(panRafId); } }; - }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]); + }, [canPan, panByScreenDelta, scaleZoom]); return ( <PreviewViewportProvider value={viewport}> @@ -329,7 +335,7 @@ function PreviewCanvas({ </ContextMenuTrigger> <PreviewContextMenu onToggleFullscreen={onToggleFullscreen} - containerRef={containerRef} + container={container} overlayControls={overlayControls} onOverlayVisibilityChange={onOverlayVisibilityChange} /> diff --git a/apps/web/src/preview/components/mask-handles.tsx b/apps/web/src/preview/components/mask-handles.tsx index 340cab0a..314984ef 100644 --- a/apps/web/src/preview/components/mask-handles.tsx +++ b/apps/web/src/preview/components/mask-handles.tsx @@ -3,6 +3,7 @@ import { PEN_CURSOR } from "@/preview/components/cursors"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { useMaskHandles } from "@/masks/use-mask-handles"; +import { maskHandleIdKey, type MaskHandleId } from "@/masks/types"; import type { SnapLine } from "@/preview/preview-snap"; import { CornerHandle, @@ -16,7 +17,6 @@ import { } from "./handle-primitives"; const CUSTOM_MASK_ANCHOR_SIZE = 7; -const CUSTOM_MASK_TANGENT_SIZE = 6; import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons"; export function MaskHandles({ @@ -29,7 +29,7 @@ export function MaskHandles({ selectedWithMask, handlePositions, overlays, - isCreatingCustomMask, + isCreatingFreeformPathMask, handleCanvasPointerDown, handlePointerDown, handlePointerMove, @@ -72,7 +72,7 @@ export function MaskHandles({ handleId, }: { event: React.PointerEvent; - handleId: string; + handleId: MaskHandleId; }) => { if (viewport.handlePanPointerDown({ event })) { return; @@ -93,7 +93,7 @@ export function MaskHandles({ className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden > - {isCreatingCustomMask ? ( + {isCreatingFreeformPathMask ? ( <div className="absolute inset-0 pointer-events-auto" style={{ cursor: PEN_CURSOR }} @@ -193,11 +193,12 @@ export function MaskHandles({ })} {handlePositions.map((handle) => { const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); + const key = maskHandleIdKey({ id: handle.id }); if (handle.kind === "icon" && handle.icon === "rotate") { return ( <IconHandle - key={handle.id} + key={key} icon={Rotate01Icon} screen={screen} onPointerDown={(event) => @@ -212,7 +213,7 @@ export function MaskHandles({ if (handle.kind === "icon" && handle.icon === "feather") { return ( <IconHandle - key={handle.id} + key={key} icon={FeatherIcon} screen={screen} onPointerDown={(event) => @@ -227,7 +228,7 @@ export function MaskHandles({ if (handle.kind === "edge" && handle.edgeAxis === "horizontal") { return ( <EdgeHandle - key={handle.id} + key={key} edge="right" screen={screen} rotation={handle.rotation ?? 0} @@ -243,7 +244,7 @@ export function MaskHandles({ if (handle.kind === "edge" && handle.edgeAxis === "vertical") { return ( <EdgeHandle - key={handle.id} + key={key} edge="bottom" screen={screen} rotation={handle.rotation ?? 0} @@ -256,30 +257,26 @@ export function MaskHandles({ ); } - if (handle.kind === "point" || handle.kind === "tangent") { - return ( - <CircleHandle - key={handle.id} - screen={screen} - size={ - handle.kind === "tangent" - ? CUSTOM_MASK_TANGENT_SIZE - : CUSTOM_MASK_ANCHOR_SIZE - } - isSelected={handle.isSelected} - onPointerDown={(event) => - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } + if (handle.kind === "point") { + return ( + <CircleHandle + key={key} + screen={screen} + size={CUSTOM_MASK_ANCHOR_SIZE} + isSelected={handle.isSelected} + onPointerDown={(event) => + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } if (handle.kind === "corner") { return ( <CornerHandle - key={handle.id} + key={key} screen={screen} onPointerDown={(event) => handleMaskPointerDown({ event, handleId: handle.id }) @@ -292,7 +289,7 @@ export function MaskHandles({ return ( <CornerHandle - key={handle.id} + key={key} cursor={handle.cursor} screen={screen} onPointerDown={(event) => diff --git a/apps/web/src/preview/components/preview-viewport.tsx b/apps/web/src/preview/components/preview-viewport.tsx index 6dc9f084..be261adc 100644 --- a/apps/web/src/preview/components/preview-viewport.tsx +++ b/apps/web/src/preview/components/preview-viewport.tsx @@ -9,6 +9,7 @@ import { useRef,
useState,
} from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import {
canvasToOverlay,
getDisplayScale,
@@ -219,8 +220,7 @@ export function usePreviewViewportState({ }));
const [isPanning, setIsPanning] = useState(false);
const panSessionRef = useRef<PanSession | null>(null);
- const centerRef = useRef(center);
- centerRef.current = center;
+ const centerRef = useCommittedRef(center);
const fitScale = useMemo(
() =>
@@ -409,10 +409,10 @@ export function usePreviewViewportState({ pointerId: event.pointerId,
};
setIsPanning(true);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ event.currentTarget.setPointerCapture(event.pointerId);
return true;
},
- [zoom],
+ [centerRef, zoom],
);
const handlePanPointerMove = useCallback(
@@ -451,13 +451,9 @@ export function usePreviewViewportState({ }
if (
- (event.currentTarget as HTMLElement).hasPointerCapture(
- panSession.pointerId,
- )
+ event.currentTarget.hasPointerCapture(panSession.pointerId)
) {
- (event.currentTarget as HTMLElement).releasePointerCapture(
- panSession.pointerId,
- );
+ event.currentTarget.releasePointerCapture(panSession.pointerId);
}
panSessionRef.current = null;
diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx index 1d599b3b..c06d50ed 100644 --- a/apps/web/src/preview/components/text-edit-overlay.tsx +++ b/apps/web/src/preview/components/text-edit-overlay.tsx @@ -9,7 +9,12 @@ import { getElementLocalTime, } from "@/animation"; import { resolveTransformAtTime } from "@/rendering/animation-values"; +import { buildTransformFromParams } from "@/rendering"; import { resolveTextLayout } from "@/text/primitives"; +import { + buildTextBackgroundFromElement, + buildTextLayoutParamsFromElement, +} from "@/text/measure-element"; export function TextEditOverlay({ trackId, @@ -42,7 +47,7 @@ export function TextEditOverlay({ if (!div) return; const text = div.innerText; editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: { content: text } }], + updates: [{ trackId, elementId, updates: { params: { content: text } } }], }); }, [editor.timeline, trackId, elementId]); @@ -69,7 +74,7 @@ export function TextEditOverlay({ elementDuration: element.duration, }); const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -80,26 +85,17 @@ export function TextEditOverlay({ }); const { x: displayScaleX } = viewport.getDisplayScale(); + const textParams = buildTextLayoutParamsFromElement({ element }); const resolvedTextLayout = resolveTextLayout({ - text: { - content: element.content, - fontSize: element.fontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textAlign: element.textAlign, - textDecoration: element.textDecoration, - letterSpacing: element.letterSpacing, - lineHeight: element.lineHeight, - }, + text: textParams, canvasHeight: canvasSize.height, }); - const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; - const canvasLetterSpacing = element.letterSpacing ?? 0; + const lineHeight = textParams.lineHeight ?? DEFAULTS.text.lineHeight; + const canvasLetterSpacing = textParams.letterSpacing ?? 0; const lineHeightPx = resolvedTextLayout.lineHeightPx; - const bg = element.background; + const bg = buildTextBackgroundFromElement({ element }); const shouldShowBackground = bg.enabled && bg.color && bg.color !== "transparent"; const fontSizeRatio = resolvedTextLayout.fontSizeRatio; @@ -120,7 +116,6 @@ export function TextEditOverlay({ transformOrigin: "center center", }} > - {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} <div ref={divRef} contentEditable @@ -131,17 +126,20 @@ export function TextEditOverlay({ className="cursor-text select-text outline-none whitespace-pre" style={{ fontSize: resolvedTextLayout.scaledFontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight === "bold" ? "bold" : "normal", - fontStyle: element.fontStyle === "italic" ? "italic" : "normal", - textAlign: element.textAlign, + fontFamily: textParams.fontFamily, + fontWeight: textParams.fontWeight === "bold" ? "bold" : "normal", + fontStyle: textParams.fontStyle === "italic" ? "italic" : "normal", + textAlign: textParams.textAlign, letterSpacing: `${canvasLetterSpacing}px`, lineHeight, color: "transparent", - caretColor: element.color, + caretColor: + typeof element.params.color === "string" + ? element.params.color + : "#ffffff", backgroundColor: shouldShowBackground ? bg.color : "transparent", minHeight: lineHeightPx, - textDecoration: element.textDecoration ?? "none", + textDecoration: textParams.textDecoration ?? "none", padding: shouldShowBackground ? `${canvasPaddingY}px ${canvasPaddingX}px` : 0, @@ -151,7 +149,7 @@ export function TextEditOverlay({ onBlur={onCommit} onKeyDown={(event) => handleKeyDown({ event })} > - {element.content || ""} + {textParams.content} </div> </div> ); diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx index 783dd239..1a5ce1bc 100644 --- a/apps/web/src/preview/components/toolbar.tsx +++ b/apps/web/src/preview/components/toolbar.tsx @@ -22,6 +22,9 @@ import { } from "@/components/ui/select"; import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom"; import { usePreviewViewport } from "./preview-viewport"; +import { GridPopover } from "./guide-popover"; +import { usePreviewStore } from "@/preview/preview-store"; +import type { MediaTime } from "@/wasm"; export function PreviewToolbar({ onToggleFullscreen, @@ -60,7 +63,7 @@ function TimecodeDisplay() { const editor = useEditor(); const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); const fps = useEditor((e) => e.project.getActive().settings.fps); - const [currentTime, setCurrentTime] = useState(() => + const [currentTime, setCurrentTime] = useState<MediaTime>(() => editor.playback.getCurrentTime(), ); diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts index 00573662..118b48ae 100644 --- a/apps/web/src/preview/controllers/preview-interaction-controller.ts +++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts @@ -18,7 +18,8 @@ import { type SnapLine, } from "@/preview/preview-snap"; import type { TCanvasSize } from "@/project/types"; -import type { Transform } from "@/rendering"; +import type { ParamValues } from "@/params"; +import { buildTransformFromParams, type Transform } from "@/rendering"; import { isVisualElement } from "@/timeline/element-utils"; import type { ElementRef, @@ -51,6 +52,7 @@ interface DragElementSnapshot { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; } interface DraggingGesture extends CapturedPointerState { @@ -218,7 +220,8 @@ function toDragElementSnapshots({ .map(({ track, element }) => ({ trackId: track.id, elementId: element.id, - initialTransform: element.transform, + initialTransform: buildTransformFromParams({ params: element.params }), + initialParams: element.params, })); } @@ -565,16 +568,14 @@ export class PreviewInteractionController { snappedPosition.y - firstElement.initialTransform.position.y; this.deps.timeline.previewElements( - drag.elements.map(({ trackId, elementId, initialTransform }) => ({ + drag.elements.map(({ trackId, elementId, initialTransform, initialParams }) => ({ trackId, elementId, updates: { - transform: { - ...initialTransform, - position: { - x: initialTransform.position.x + deltaSnappedX, - y: initialTransform.position.y + deltaSnappedY, - }, + params: { + ...initialParams, + "transform.positionX": initialTransform.position.x + deltaSnappedX, + "transform.positionY": initialTransform.position.y + deltaSnappedY, }, }, })), diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts index 67f677f6..d9b5ef1f 100644 --- a/apps/web/src/preview/controllers/transform-handle-controller.ts +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -23,7 +23,8 @@ import { setChannel, } from "@/animation"; import type { ElementAnimations } from "@/animation/types"; -import type { Transform } from "@/rendering"; +import type { ParamValues } from "@/params"; +import { buildTransformFromParams, type Transform } from "@/rendering"; import { resolveTransformAtTime } from "@/rendering/animation-values"; import type { ElementRef, @@ -47,6 +48,7 @@ interface CornerScaleSession extends CapturedPointerState { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; readonly initialDistance: number; readonly initialBoundsCx: number; readonly initialBoundsCy: number; @@ -62,6 +64,7 @@ interface EdgeScaleSession extends CapturedPointerState { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; readonly initialBoundsCx: number; readonly initialBoundsCy: number; readonly baseWidth: number; @@ -76,6 +79,7 @@ interface RotationSession extends CapturedPointerState { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; readonly initialAngle: number; readonly initialBoundsCx: number; readonly initialBoundsCy: number; @@ -369,6 +373,7 @@ export class TransformHandleController { trackId: context.trackId, elementId: context.elementId, initialTransform: context.resolvedTransform, + initialParams: context.element.params, initialDistance: getCornerDistance({ bounds: context.bounds, corner, @@ -410,6 +415,7 @@ export class TransformHandleController { trackId: context.trackId, elementId: context.elementId, initialTransform: context.resolvedTransform, + initialParams: context.element.params, initialAngle, initialBoundsCx: context.bounds.cx, initialBoundsCy: context.bounds.cy, @@ -447,6 +453,7 @@ export class TransformHandleController { trackId: context.trackId, elementId: context.elementId, initialTransform: context.resolvedTransform, + initialParams: context.element.params, initialBoundsCx: context.bounds.cx, initialBoundsCy: context.bounds.cy, baseWidth: context.bounds.width / context.resolvedTransform.scaleX, @@ -561,7 +568,9 @@ export class TransformHandleController { element: selectedWithBounds.element, bounds: selectedWithBounds.bounds, resolvedTransform: resolveTransformAtTime({ - baseTransform: selectedWithBounds.element.transform, + baseTransform: buildTransformFromParams({ + params: selectedWithBounds.element.params, + }), animations: selectedWithBounds.element.animations, localTime, }), @@ -608,15 +617,18 @@ export class TransformHandleController { trackId: session.trackId, elementId: session.elementId, updates: { - transform: { - ...session.initialTransform, - scaleX: clampScaleNonZero( - session.initialTransform.scaleX * snappedScale, - ), - scaleY: clampScaleNonZero( - session.initialTransform.scaleY * snappedScale, - ), - }, + params: buildParamsWithTransform({ + params: session.initialParams, + transform: { + ...session.initialTransform, + scaleX: clampScaleNonZero( + session.initialTransform.scaleX * snappedScale, + ), + scaleY: clampScaleNonZero( + session.initialTransform.scaleY * snappedScale, + ), + }, + }), ...(session.shouldClearScaleAnimation && { animations: session.animationsWithoutScale, }), @@ -699,17 +711,20 @@ export class TransformHandleController { trackId: session.trackId, elementId: session.elementId, updates: { - transform: { - ...session.initialTransform, - scaleX: - session.edge === "right" || session.edge === "left" - ? xSnap.snappedScale - : session.initialTransform.scaleX, - scaleY: - session.edge === "bottom" - ? ySnap.snappedScale - : session.initialTransform.scaleY, - }, + params: buildParamsWithTransform({ + params: session.initialParams, + transform: { + ...session.initialTransform, + scaleX: + session.edge === "right" || session.edge === "left" + ? xSnap.snappedScale + : session.initialTransform.scaleX, + scaleY: + session.edge === "bottom" + ? ySnap.snappedScale + : session.initialTransform.scaleY, + }, + }), ...(session.shouldClearScaleAnimation && { animations: session.animationsWithoutScale, }), @@ -742,12 +757,32 @@ export class TransformHandleController { trackId: session.trackId, elementId: session.elementId, updates: { - transform: { - ...session.initialTransform, - rotate: snappedRotation, - }, + params: buildParamsWithTransform({ + params: session.initialParams, + transform: { + ...session.initialTransform, + rotate: snappedRotation, + }, + }), }, }, ]); } } + +function buildParamsWithTransform({ + params, + transform, +}: { + params: ParamValues; + transform: Transform; +}): ParamValues { + return { + ...params, + "transform.positionX": transform.position.x, + "transform.positionY": transform.position.y, + "transform.scaleX": transform.scaleX, + "transform.scaleY": transform.scaleY, + "transform.rotate": transform.rotate, + }; +} diff --git a/apps/web/src/preview/element-bounds.ts b/apps/web/src/preview/element-bounds.ts index 0afc6e4e..181fdf1f 100644 --- a/apps/web/src/preview/element-bounds.ts +++ b/apps/web/src/preview/element-bounds.ts @@ -7,6 +7,7 @@ import { getElementLocalTime, } from "@/animation"; import { resolveTransformAtTime } from "@/rendering/animation-values"; +import { buildTransformFromParams } from "@/rendering"; export interface ElementBounds { cx: number; @@ -123,7 +124,7 @@ function getElementBounds({ if (element.type === "video" || element.type === "image") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -140,7 +141,7 @@ function getElementBounds({ if (element.type === "sticker") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -155,7 +156,7 @@ function getElementBounds({ if (element.type === "graphic") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -170,7 +171,7 @@ function getElementBounds({ if (element.type === "text") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts index d093dcf2..b707f1e6 100644 --- a/apps/web/src/preview/hooks/use-preview-interaction.ts +++ b/apps/web/src/preview/hooks/use-preview-interaction.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import type { SnapLine } from "@/preview/preview-snap"; @@ -59,17 +60,10 @@ export function usePreviewInteraction({ onSnapLinesChange, }, }; - - const depsRef = useRef<PreviewInteractionDeps>(deps); - depsRef.current = deps; - - const controllerRef = useRef<PreviewInteractionController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new PreviewInteractionController({ - depsRef: depsRef as PreviewInteractionDepsRef, - }); - } - const controller = controllerRef.current; + const depsRef = useCommittedRef(deps) as PreviewInteractionDepsRef; + const [controller] = useState( + () => new PreviewInteractionController({ depsRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect( diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts index f4b7e306..55d8d8f7 100644 --- a/apps/web/src/preview/hooks/use-transform-handles.ts +++ b/apps/web/src/preview/hooks/use-transform-handles.ts @@ -1,7 +1,8 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { registerCanceller } from "@/editor/cancel-interaction"; import { @@ -48,15 +49,10 @@ export function useTransformHandles({ onSnapLinesChange, }, }; - - const depsRef = useRef<TransformHandleDeps>(deps); - depsRef.current = deps; - - const controllerRef = useRef<TransformHandleController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new TransformHandleController({ depsRef }); - } - const controller = controllerRef.current; + const depsRef = useCommittedRef(deps); + const [controller] = useState( + () => new TransformHandleController({ depsRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/project/types.ts b/apps/web/src/project/types.ts index 42e844f6..dca2d785 100644 --- a/apps/web/src/project/types.ts +++ b/apps/web/src/project/types.ts @@ -1,5 +1,6 @@ import type { FrameRate } from "opencut-wasm"; import type { TScene } from "@/timeline/types"; +import type { MediaTime } from "@/wasm"; export type TBackground = | { @@ -20,7 +21,7 @@ export interface TProjectMetadata { id: string; name: string; thumbnail?: string; - duration: number; + duration: MediaTime; createdAt: Date; updatedAt: Date; } @@ -37,7 +38,7 @@ export interface TProjectSettings { export interface TTimelineViewState { zoomLevel: number; scrollLeft: number; - playheadTime: number; + playheadTime: MediaTime; } export interface TProject { diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx deleted file mode 100644 index 48b1d0e5..00000000 --- a/apps/web/src/rendering/components/blending-tab.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { useEditor } from "@/editor/use-editor"; -import { clamp } from "@/utils/math"; -import { NumberField } from "@/components/ui/number-field"; -import { OcCheckerboardIcon } from "@/components/icons"; -import { Fragment, useRef } from "react"; -import { useMenuPreview } from "@/editor/use-menu-preview"; -import { - Section, - SectionContent, - SectionField, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { BlendMode } from "@/rendering"; -import type { ElementType } from "@/timeline"; -import type { ElementAnimations } from "@/animation/types"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { RainDropIcon } from "@hugeicons/core-free-icons"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/animation/values"; -import { DEFAULTS } from "@/timeline/defaults"; -import { isPropertyAtDefault } from "./transform-tab"; - -type BlendingElement = { - id: string; - opacity: number; - type: ElementType; - blendMode?: BlendMode; - startTime: number; - duration: number; - animations?: ElementAnimations; -}; - -const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [ - [{ value: "normal", label: "Normal" }], - [ - { value: "darken", label: "Darken" }, - { value: "multiply", label: "Multiply" }, - { value: "color-burn", label: "Color Burn" }, - ], - [ - { value: "lighten", label: "Lighten" }, - { value: "screen", label: "Screen" }, - { value: "plus-lighter", label: "Plus Lighter" }, - { value: "color-dodge", label: "Color Dodge" }, - ], - [ - { value: "overlay", label: "Overlay" }, - { value: "soft-light", label: "Soft Light" }, - { value: "hard-light", label: "Hard Light" }, - ], - [ - { value: "difference", label: "Difference" }, - { value: "exclusion", label: "Exclusion" }, - ], - [ - { value: "hue", label: "Hue" }, - { value: "saturation", label: "Saturation" }, - { value: "color", label: "Color" }, - { value: "luminosity", label: "Luminosity" }, - ], -]; - -export function BlendingTab({ - element, - trackId, -}: { - element: BlendingElement; - trackId: string; -}) { - const editor = useEditor(); - const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); - const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; - const committedBlendModeRef = useRef(blendMode); - if (!isPreviewActive) { - committedBlendModeRef.current = blendMode; - } - - const { - onPointerLeave, - onOpenChange: handleBlendModeOpenChange, - markCommitted, - } = useMenuPreview(); - - const previewBlendMode = ({ value }: { value: BlendMode }) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { blendMode: value } }, - ], - }); - - const commitBlendMode = (value: BlendMode) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { blendMode: value }, - }, - ], - }); - } - markCommitted(); - }; - - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedOpacity = resolveOpacityAtTime({ - baseOpacity: element.opacity, - animations: element.animations, - localTime, - }); - - const opacity = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "opacity", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOpacity * 100).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min: 0, max: 100 }) / 100; - }, - valueAtPlayhead: resolvedOpacity, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ opacity: value }), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:blending`}> - <SectionHeader> - <SectionTitle>Blending</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField - label="Opacity" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={opacity.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle opacity keyframe" - onToggle={opacity.toggleKeyframe} - /> - } - > - <NumberField - className="w-full" - icon={ - <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> - } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => - opacity.commitValue({ value: DEFAULTS.element.opacity }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOpacity, - staticValue: element.opacity, - defaultValue: DEFAULTS.element.opacity, - })} - dragSensitivity="slow" - /> - </SectionField> - <SectionField label="Blend mode" className="w-1/2"> - <Select - value={committedBlendModeRef.current} - onOpenChange={handleBlendModeOpenChange} - onValueChange={commitBlendMode} - > - <SelectTrigger - icon={<HugeiconsIcon icon={RainDropIcon} />} - className="w-full" - > - <SelectValue placeholder="Select blend mode" /> - </SelectTrigger> - <SelectContent className="w-36" onPointerLeave={onPointerLeave}> - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> - {group.map((option) => ( - <SelectItem - key={option.value} - value={option.value} - onPointerEnter={() => - previewBlendMode({ value: option.value }) - } - > - {option.label} - </SelectItem> - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - <SelectSeparator /> - ) : null} - </Fragment> - ))} - </SelectContent> - </Select> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/rendering/components/transform-tab.tsx b/apps/web/src/rendering/components/transform-tab.tsx deleted file mode 100644 index f4d5a183..00000000 --- a/apps/web/src/rendering/components/transform-tab.tsx +++ /dev/null @@ -1,462 +0,0 @@ -import { NumberField } from "@/components/ui/number-field"; -import { useEditor } from "@/editor/use-editor"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { VisualElement } from "@/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowExpandIcon, - Link05Icon, - RotateClockwiseIcon, -} from "@hugeicons/core-free-icons"; -import { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, -} from "@/animation"; -import { resolveTransformAtTime } from "@/rendering/animation-values"; -import { DEFAULTS } from "@/timeline/defaults"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; - -export function parseNumericInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : parsed; -} - -export function isPropertyAtDefault({ - hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue, - staticValue, - defaultValue, -}: { - hasAnimatedKeyframes: boolean; - isPlayheadWithinElementRange: boolean; - resolvedValue: number; - staticValue: number; - defaultValue: number; -}): boolean { - if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { - return isNearlyEqual({ - leftValue: resolvedValue, - rightValue: defaultValue, - }); - } - - return staticValue === defaultValue; -} - -export function TransformTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const editor = useEditor(); - const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); - const setTransformScaleLocked = usePropertiesStore( - (s) => s.setTransformScaleLocked, - ); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const positionX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.positionX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.x).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.x, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { ...element.transform.position, x: value }, - }, - }), - }); - - const positionY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.positionY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.y).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.y, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { ...element.transform.position, y: value }, - }, - }), - }); - - const parseScale = (input: string) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return parsed / 100; - }; - - const scaleX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleX, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleX: value, - ...(isScaleLocked ? { scaleY: value } : {}), - }, - }), - buildAdditionalKeyframes: isScaleLocked - ? ({ value }) => [{ propertyPath: "transform.scaleY", value }] - : undefined, - }); - - const scaleY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleY, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleY: value, - ...(isScaleLocked ? { scaleX: value } : {}), - }, - }), - buildAdditionalKeyframes: isScaleLocked - ? ({ value }) => [{ propertyPath: "transform.scaleX", value }] - : undefined, - }); - - const scaleFieldPropsX = { - value: scaleX.displayValue, - onFocus: scaleX.onFocus, - onChange: scaleX.onChange, - onBlur: scaleX.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleX.scrubTo, - onScrubEnd: scaleX.commitScrub, - onReset: () => - scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleX, - staticValue: element.transform.scaleX, - defaultValue: DEFAULTS.element.transform.scaleX, - }), - }; - - const scaleFieldPropsY = { - value: scaleY.displayValue, - onFocus: scaleY.onFocus, - onChange: scaleY.onChange, - onBlur: scaleY.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleY.scrubTo, - onScrubEnd: scaleY.commitScrub, - onReset: () => - scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleY, - staticValue: element.transform.scaleY, - defaultValue: DEFAULTS.element.transform.scaleY, - }), - }; - - const rotation = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.rotate", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.rotate).toString(), - parse: (input) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return clamp({ value: parsed, min: -360, max: 360 }); - }, - valueAtPlayhead: resolvedTransform.rotate, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - rotate: value, - }, - }), - }); - - const hasScaleKeyframe = hasGroupKeyframeAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - - const toggleScaleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - const existing = getGroupKeyframesAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - if (existing.length > 0) { - editor.timeline.removeKeyframes({ - keyframes: existing.map((ref) => ({ - trackId, - elementId: element.id, - ...ref, - })), - }); - return; - } - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleX", - time: localTime, - value: resolvedTransform.scaleX, - }, - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleY", - time: localTime, - value: resolvedTransform.scaleY, - }, - ], - }); - }; - - const scaleLockButton = ( - <Button - type="button" - variant={isScaleLocked ? "secondary" : "ghost"} - size="icon" - aria-pressed={isScaleLocked} - onClick={() => setTransformScaleLocked(!isScaleLocked)} - > - <HugeiconsIcon icon={Link05Icon} /> - </Button> - ); - - return ( - <Section collapsible sectionKey={`${element.id}:transform`}> - <SectionHeader> - <SectionTitle>Transform</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <div className="flex items-end gap-2"> - {isScaleLocked ? ( - <> - <SectionField - label="Scale" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={hasScaleKeyframe} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle scale keyframe" - onToggle={toggleScaleKeyframe} - /> - } - > - <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldPropsX} - /> - </SectionField> - {scaleLockButton} - </> - ) : ( - <> - <SectionField - label="Width" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle width scale keyframe" - onToggle={scaleX.toggleKeyframe} - /> - } - > - <NumberField icon="W" {...scaleFieldPropsX} /> - </SectionField> - {scaleLockButton} - <SectionField - label="Height" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle height scale keyframe" - onToggle={scaleY.toggleKeyframe} - /> - } - > - <NumberField icon="H" {...scaleFieldPropsY} /> - </SectionField> - </> - )} - </div> - <div className="flex items-end gap-2"> - <SectionField - label="X" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={positionX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle X position keyframe" - onToggle={positionX.toggleKeyframe} - /> - } - > - <NumberField - icon="X" - value={positionX.displayValue} - onFocus={positionX.onFocus} - onChange={positionX.onChange} - onBlur={positionX.onBlur} - onScrub={positionX.scrubTo} - onScrubEnd={positionX.commitScrub} - onReset={() => - positionX.commitValue({ - value: DEFAULTS.element.transform.position.x, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.x, - staticValue: element.transform.position.x, - defaultValue: DEFAULTS.element.transform.position.x, - })} - /> - </SectionField> - <SectionField - label="Y" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={positionY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle Y position keyframe" - onToggle={positionY.toggleKeyframe} - /> - } - > - <NumberField - icon="Y" - value={positionY.displayValue} - onFocus={positionY.onFocus} - onChange={positionY.onChange} - onBlur={positionY.onBlur} - onScrub={positionY.scrubTo} - onScrubEnd={positionY.commitScrub} - onReset={() => - positionY.commitValue({ - value: DEFAULTS.element.transform.position.y, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.y, - staticValue: element.transform.position.y, - defaultValue: DEFAULTS.element.transform.position.y, - })} - /> - </SectionField> - </div> - - <SectionField - label="Rotation" - beforeLabel={ - <KeyframeToggle - isActive={rotation.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle rotation keyframe" - onToggle={rotation.toggleKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - <NumberField - icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} - className="flex-none" - value={rotation.displayValue} - onFocus={rotation.onFocus} - onChange={rotation.onChange} - onBlur={rotation.onBlur} - dragSensitivity="slow" - onScrub={rotation.scrubTo} - onScrubEnd={rotation.commitScrub} - onReset={() => - rotation.commitValue({ - value: DEFAULTS.element.transform.rotate, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.rotate, - staticValue: element.transform.rotate, - defaultValue: DEFAULTS.element.transform.rotate, - })} - /> - </div> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/rendering/index.ts b/apps/web/src/rendering/index.ts index c5555371..21e66447 100644 --- a/apps/web/src/rendering/index.ts +++ b/apps/web/src/rendering/index.ts @@ -1,3 +1,5 @@ +import type { ParamValues } from "@/params";
+
export interface Transform {
scaleX: number;
scaleY: number;
@@ -26,3 +28,71 @@ export type BlendMode = | "saturation"
| "color"
| "luminosity";
+
+export function buildTransformFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): Transform {
+ return {
+ scaleX: readNumberParam({ params, key: "transform.scaleX", fallback: 1 }),
+ scaleY: readNumberParam({ params, key: "transform.scaleY", fallback: 1 }),
+ position: {
+ x: readNumberParam({ params, key: "transform.positionX", fallback: 0 }),
+ y: readNumberParam({ params, key: "transform.positionY", fallback: 0 }),
+ },
+ rotate: readNumberParam({ params, key: "transform.rotate", fallback: 0 }),
+ };
+}
+
+export function readOpacityFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): number {
+ return readNumberParam({ params, key: "opacity", fallback: 1 });
+}
+
+export function readBlendModeFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): BlendMode {
+ const value = params.blendMode;
+ return typeof value === "string" && isBlendMode(value) ? value : "normal";
+}
+
+function readNumberParam({
+ params,
+ key,
+ fallback,
+}: {
+ params: ParamValues;
+ key: string;
+ fallback: number;
+}): number {
+ const value = params[key];
+ return typeof value === "number" ? value : fallback;
+}
+
+function isBlendMode(value: string): value is BlendMode {
+ return (
+ value === "normal" ||
+ value === "darken" ||
+ value === "multiply" ||
+ value === "color-burn" ||
+ value === "lighten" ||
+ value === "screen" ||
+ value === "plus-lighter" ||
+ value === "color-dodge" ||
+ value === "overlay" ||
+ value === "soft-light" ||
+ value === "hard-light" ||
+ value === "difference" ||
+ value === "exclusion" ||
+ value === "hue" ||
+ value === "saturation" ||
+ value === "color" ||
+ value === "luminosity"
+ );
+}
diff --git a/apps/web/src/selection/hooks/use-box-select.ts b/apps/web/src/selection/hooks/use-box-select.ts index 90a584b5..3bbff14e 100644 --- a/apps/web/src/selection/hooks/use-box-select.ts +++ b/apps/web/src/selection/hooks/use-box-select.ts @@ -4,15 +4,40 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { BoxSelectionSnapshot, ResolveIntersections, + SelectionBoxBounds, } from "@/selection/types"; interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> { startPos: { x: number; y: number }; currentPos: { x: number; y: number }; + bounds: SelectionBoxBounds | null; isActive: boolean; isAdditive: boolean; } +function getSelectionBoxBounds({ + container, + startPos, + currentPos, +}: { + container: HTMLElement; + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; +}): SelectionBoxBounds { + const containerRect = container.getBoundingClientRect(); + const startX = startPos.x - containerRect.left; + const startY = startPos.y - containerRect.top; + const currentX = currentPos.x - containerRect.left; + const currentY = currentPos.y - containerRect.top; + + return { + left: Math.min(startX, currentX), + top: Math.min(startY, currentY), + width: Math.abs(currentX - startX), + height: Math.abs(currentY - startY), + }; +} + export function useBoxSelect<TId>({ containerRef, resolveIntersections, @@ -40,33 +65,43 @@ export function useBoxSelect<TId>({ const [selectionBox, setSelectionBox] = useState<SelectionBoxState<TId> | null>(null); const justFinishedSelectingRef = useRef(false); - const shouldStartSelectionCheck = shouldStartSelection ?? (() => true); - const getIsAdditiveSelectionCheck = - getIsAdditiveSelection ?? - ((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey); const handleMouseDown = useCallback( (event: React.MouseEvent<Element>) => { - const canStartSelection = shouldStartSelectionCheck(event); + const canStartSelection = shouldStartSelection + ? shouldStartSelection(event) + : true; if (!isEnabled || event.button !== 0 || !canStartSelection) { return; } + const startPos = { x: event.clientX, y: event.clientY }; + const container = containerRef.current; setSelectionBox({ - startPos: { x: event.clientX, y: event.clientY }, - currentPos: { x: event.clientX, y: event.clientY }, + startPos, + currentPos: startPos, + bounds: container + ? getSelectionBoxBounds({ + container, + startPos, + currentPos: startPos, + }) + : null, isActive: false, - isAdditive: getIsAdditiveSelectionCheck(event), + isAdditive: getIsAdditiveSelection + ? getIsAdditiveSelection(event) + : event.ctrlKey || event.metaKey, initialSelectedIds: selectedIds, initialAnchorId: anchorId, }); }, [ anchorId, - getIsAdditiveSelectionCheck, + containerRef, + getIsAdditiveSelection, isEnabled, selectedIds, - shouldStartSelectionCheck, + shouldStartSelection, ], ); @@ -98,11 +133,20 @@ export function useBoxSelect<TId>({ } const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { + const currentPos = { x: clientX, y: clientY }; const deltaX = Math.abs(clientX - selectionBox.startPos.x); const deltaY = Math.abs(clientY - selectionBox.startPos.y); + const container = containerRef.current; const nextSelectionBox = { ...selectionBox, - currentPos: { x: clientX, y: clientY }, + currentPos, + bounds: container + ? getSelectionBoxBounds({ + container, + startPos: selectionBox.startPos, + currentPos, + }) + : null, isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive, }; @@ -133,26 +177,26 @@ export function useBoxSelect<TId>({ window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; - }, [selectionBox, updateSelection]); + }, [containerRef, selectionBox, updateSelection]); useEffect(() => { if (!selectionBox) { return; } + const container = containerRef.current; const previousBodyUserSelect = document.body.style.userSelect; - const previousContainerUserSelect = - containerRef.current?.style.userSelect ?? ""; + const previousContainerUserSelect = container?.style.userSelect ?? ""; document.body.style.userSelect = "none"; - if (containerRef.current) { - containerRef.current.style.userSelect = "none"; + if (container) { + container.style.userSelect = "none"; } return () => { document.body.style.userSelect = previousBodyUserSelect; - if (containerRef.current) { - containerRef.current.style.userSelect = previousContainerUserSelect; + if (container) { + container.style.userSelect = previousContainerUserSelect; } }; }, [containerRef, selectionBox]); @@ -162,7 +206,10 @@ export function useBoxSelect<TId>({ }, []); return { - selectionBox, + selectionBox: + selectionBox?.isActive && selectionBox.bounds + ? { bounds: selectionBox.bounds } + : null, handleMouseDown, isSelecting: selectionBox?.isActive ?? false, shouldIgnoreClick, diff --git a/apps/web/src/selection/hooks/use-selection-scope.ts b/apps/web/src/selection/hooks/use-selection-scope.ts index ad4d2ce8..7fddab08 100644 --- a/apps/web/src/selection/hooks/use-selection-scope.ts +++ b/apps/web/src/selection/hooks/use-selection-scope.ts @@ -1,28 +1,25 @@ -import { useEffect, useRef } from "react";
+import { useEffect, useState } from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useSelectionContext } from "@/selection/context";
import { activateScope, type ScopeEntry } from "@/selection/scope";
export function useSelectionScope() {
const { selectedIds, clearSelection } = useSelectionContext();
- const hasSelectionRef = useRef(selectedIds.length > 0);
- const clearSelectionRef = useRef(clearSelection);
const hasSelection = selectedIds.length > 0;
-
- hasSelectionRef.current = hasSelection;
- clearSelectionRef.current = clearSelection;
-
- const entryRef = useRef<ScopeEntry>({
+ const hasSelectionRef = useCommittedRef(hasSelection);
+ const clearSelectionRef = useCommittedRef(clearSelection);
+ const [entry] = useState<ScopeEntry>(() => ({
hasSelection: () => hasSelectionRef.current,
clear: () => {
clearSelectionRef.current();
},
- });
+ }));
useEffect(() => {
if (!hasSelection) {
return;
}
- return activateScope({ entry: entryRef.current });
- }, [hasSelection]);
+ return activateScope({ entry });
+ }, [entry, hasSelection]);
}
diff --git a/apps/web/src/selection/selectable-item.tsx b/apps/web/src/selection/selectable-item.tsx index cc9e1dbf..9735c4a8 100644 --- a/apps/web/src/selection/selectable-item.tsx +++ b/apps/web/src/selection/selectable-item.tsx @@ -6,7 +6,13 @@ import { SELECTABLE_ITEM_ATTRIBUTE } from "@/selection/attributes"; import type { SelectableItemProps } from "@/selection/types";
import { cn } from "@/utils/ui";
-function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
+function setForwardedRef<T>({
+ ref,
+ value,
+}: {
+ ref: React.ForwardedRef<T>;
+ value: T | null;
+}) {
if (typeof ref === "function") {
ref(value);
return;
@@ -55,7 +61,7 @@ export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>( const handleRef = useCallback(
(element: HTMLDivElement | null) => {
registerItem(id, element);
- setForwardedRef(forwardedRef, element);
+ setForwardedRef({ ref: forwardedRef, value: element });
},
[forwardedRef, id, registerItem],
);
diff --git a/apps/web/src/selection/selectable-surface.tsx b/apps/web/src/selection/selectable-surface.tsx index 24166daf..12682ba8 100644 --- a/apps/web/src/selection/selectable-surface.tsx +++ b/apps/web/src/selection/selectable-surface.tsx @@ -167,14 +167,15 @@ export function SelectableSurface({ [],
);
- const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({
- containerRef,
- resolveIntersections,
- selectedIds: selectionState.selectedIds,
- anchorId: selectionState.anchorId,
- onSelectionChange: handleBoxSelectionChange,
- shouldStartSelection,
- });
+ const { selectionBox, handleMouseDown, isSelecting, shouldIgnoreClick } =
+ useBoxSelect({
+ containerRef,
+ resolveIntersections,
+ selectedIds: selectionState.selectedIds,
+ anchorId: selectionState.anchorId,
+ onSelectionChange: handleBoxSelectionChange,
+ shouldStartSelection,
+ });
const handleBackgroundClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
@@ -236,7 +237,7 @@ export function SelectableSurface({ return () => clearTimeout(timer);
}, [getItemElement, onRevealComplete, revealId]);
- const isBoxSelecting = selectionBox?.isActive ?? false;
+ const isBoxSelecting = isSelecting;
const contextValue = useMemo(() => {
return {
@@ -276,12 +277,7 @@ export function SelectableSurface({ onKeyDown={handleBackgroundKeyDown}
>
{children}
- <SelectionBox
- startPos={selectionBox?.startPos ?? null}
- currentPos={selectionBox?.currentPos ?? null}
- containerRef={containerRef}
- isActive={selectionBox?.isActive ?? false}
- />
+ <SelectionBox bounds={selectionBox?.bounds ?? null} />
</div>
</SelectionContext.Provider>
);
diff --git a/apps/web/src/selection/selection-box.tsx b/apps/web/src/selection/selection-box.tsx index e30f574d..2f2bf933 100644 --- a/apps/web/src/selection/selection-box.tsx +++ b/apps/web/src/selection/selection-box.tsx @@ -1,49 +1,22 @@ "use client";
-import { useMemo } from "react";
+import type { SelectionBoxBounds } from "@/selection/types";
interface SelectionBoxProps {
- startPos: { x: number; y: number } | null;
- currentPos: { x: number; y: number } | null;
- containerRef: React.RefObject<HTMLElement | null>;
- isActive: boolean;
+ bounds: SelectionBoxBounds | null;
}
-export function SelectionBox({
- startPos,
- currentPos,
- containerRef,
- isActive,
-}: SelectionBoxProps) {
- const selectionBoxStyle = useMemo(() => {
- if (!isActive || !startPos || !currentPos || !containerRef.current) {
- return null;
- }
-
- const containerRect = containerRef.current.getBoundingClientRect();
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const currentX = currentPos.x - containerRect.left;
- const currentY = currentPos.y - containerRect.top;
-
- const left = Math.min(startX, currentX);
- const top = Math.min(startY, currentY);
- const width = Math.abs(currentX - startX);
- const height = Math.abs(currentY - startY);
-
- return {
- left: `${left}px`,
- top: `${top}px`,
- width: `${width}px`,
- height: `${height}px`,
- };
- }, [containerRef, currentPos, isActive, startPos]);
-
- if (!selectionBoxStyle) return null;
+export function SelectionBox({ bounds }: SelectionBoxProps) {
+ if (!bounds) return null;
return (
<div
- style={selectionBoxStyle}
+ style={{
+ left: `${bounds.left}px`,
+ top: `${bounds.top}px`,
+ width: `${bounds.width}px`,
+ height: `${bounds.height}px`,
+ }}
className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
/>
);
diff --git a/apps/web/src/selection/types.ts b/apps/web/src/selection/types.ts index 31520788..a797d6c0 100644 --- a/apps/web/src/selection/types.ts +++ b/apps/web/src/selection/types.ts @@ -8,6 +8,13 @@ export interface BoxSelectionSnapshot<TId = string> { initialAnchorId: TId | null;
}
+export interface SelectionBoxBounds {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
export interface BoxSelectionChange<TId = string>
extends BoxSelectionSnapshot<TId> {
intersectedIds: TId[];
diff --git a/apps/web/src/services/renderer/canvas-renderer.ts b/apps/web/src/services/renderer/canvas-renderer.ts index 4a002675..58294e9c 100644 --- a/apps/web/src/services/renderer/canvas-renderer.ts +++ b/apps/web/src/services/renderer/canvas-renderer.ts @@ -1,5 +1,6 @@ import type { FrameRate } from "opencut-wasm"; import type { AnyBaseNode } from "./nodes/base-node"; +import { createCanvasSurface } from "./canvas-utils"; import { buildFrameDescriptor } from "./compositor/frame-descriptor"; import { wasmCompositor } from "./compositor/wasm-compositor"; import { resolveRenderTree } from "./resolve"; @@ -16,8 +17,8 @@ export type CanvasRendererParams = { }; export class CanvasRenderer { - canvas: OffscreenCanvas | HTMLCanvasElement; - context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; width: number; height: number; fps: FrameRate; @@ -27,22 +28,9 @@ export class CanvasRenderer { this.height = height; this.fps = fps; - try { - this.canvas = new OffscreenCanvas(width, height); - } catch { - this.canvas = document.createElement("canvas"); - this.canvas.width = width; - this.canvas.height = height; - } - - const context = this.canvas.getContext("2d"); - if (!context) { - throw new Error("Failed to get canvas context"); - } - - this.context = context as - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; + const surface = createCanvasSurface({ width, height }); + this.canvas = surface.canvas; + this.context = surface.context; } getOutputCanvas(): HTMLCanvasElement { @@ -57,20 +45,9 @@ export class CanvasRenderer { this.width = width; this.height = height; - if (this.canvas instanceof OffscreenCanvas) { - this.canvas = new OffscreenCanvas(width, height); - } else { - this.canvas.width = width; - this.canvas.height = height; - } - - const context = this.canvas.getContext("2d"); - if (!context) { - throw new Error("Failed to get canvas context"); - } - this.context = context as - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; + const surface = createCanvasSurface({ width, height }); + this.canvas = surface.canvas; + this.context = surface.context; } async render({ node, time }: { node: AnyBaseNode; time: number }) { diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts index 093168e6..6981e6d5 100644 --- a/apps/web/src/services/renderer/canvas-utils.ts +++ b/apps/web/src/services/renderer/canvas-utils.ts @@ -1,16 +1,17 @@ -export function createOffscreenCanvas({ +export function createCanvasSurface({ width, height, }: { width: number; height: number; -}): OffscreenCanvas | HTMLCanvasElement { - try { - return new OffscreenCanvas(width, height); - } catch { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - return canvas; +}): { + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; +} { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to create 2D rendering context"); } + return { canvas, context }; } diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts index 190a0237..d5ab88c3 100644 --- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts +++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts @@ -1,9 +1,9 @@ import { drawCssBackground } from "@/gradients"; -import { masksRegistry } from "@/masks"; +import { getMaskDefinition } from "@/masks"; import { incrementCounter } from "@/diagnostics/render-perf"; import type { AnyBaseNode } from "../nodes/base-node"; import type { CanvasRenderer } from "../canvas-renderer"; -import { createOffscreenCanvas } from "../canvas-utils"; +import { createCanvasSurface } from "../canvas-utils"; import { BlurBackgroundNode } from "../nodes/blur-background-node"; import { ColorNode } from "../nodes/color-node"; import { EffectLayerNode } from "../nodes/effect-layer-node"; @@ -390,58 +390,71 @@ function buildMaskArtifacts({ return { mask: null, strokeLayer: null }; } - const definition = masksRegistry.get(mask.type); + const definition = getMaskDefinition(mask.type); if (definition.isActive?.(mask.params) === false) { return { mask: null, strokeLayer: null }; } - let feather = mask.params.feather; - const canRenderMaskDirectly = Boolean(definition.renderer.renderMask); - const shouldRenderMaskDirectly = - canRenderMaskDirectly && - (!definition.renderer.buildPath || - (mask.params.feather > 0 && - definition.renderer.renderMaskHandlesFeather)); - if ( - shouldRenderMaskDirectly && - definition.renderer.renderMaskHandlesFeather - ) { - feather = 0; - } + const { body } = definition.renderer; + const usesOpaqueFastPath = + body.kind === "drawWithFeather" && + mask.params.feather === 0 && + Boolean(body.opaqueFastPath); + // drawWithFeather renderers encode feathering analytically in their canvas output + // (e.g. split mask uses a linear gradient instead of JFA). The descriptor feather is + // zeroed so the GPU compositor copies the mask texture as-is and does not run a second + // JFA feather pass on top of an already-soft texture. + const feather = body.kind === "drawWithFeather" ? 0 : mask.params.feather; const maskTextureId = `${path}:mask`; const { width: canvasWidth, height: canvasHeight } = renderer; - const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`; + const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:body=${body.kind}:fastPath=${usesOpaqueFastPath}`; const drawMask: TextureCanvasDrawFn = (ctx) => { - const elementMaskCanvas = createOffscreenCanvas({ - width: Math.round(transform.width), - height: Math.round(transform.height), - }); - const elementMaskCtx = elementMaskCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!elementMaskCtx) return; - - if (shouldRenderMaskDirectly && definition.renderer.renderMask) { - definition.renderer.renderMask({ - resolvedParams: mask.params, - ctx: elementMaskCtx, + const { canvas: elementMaskCanvas, context: elementMaskCtx } = + createCanvasSurface({ width: Math.round(transform.width), height: Math.round(transform.height), - feather: mask.params.feather, - }); - } else if (definition.renderer.buildPath) { - const path2d = definition.renderer.buildPath({ - resolvedParams: mask.params, - width: transform.width, - height: transform.height, }); - elementMaskCtx.fillStyle = "white"; - elementMaskCtx.fill(path2d); - } else { - return; + + switch (body.kind) { + case "fillPath": { + const path2d = body.buildPath({ + resolvedParams: mask.params, + width: transform.width, + height: transform.height, + }); + elementMaskCtx.fillStyle = "white"; + elementMaskCtx.fill(path2d); + break; + } + case "drawOpaque": + body.drawOpaque({ + resolvedParams: mask.params, + ctx: elementMaskCtx, + width: Math.round(transform.width), + height: Math.round(transform.height), + }); + break; + case "drawWithFeather": + if (usesOpaqueFastPath && body.opaqueFastPath) { + const path2d = body.opaqueFastPath.buildPath({ + resolvedParams: mask.params, + width: transform.width, + height: transform.height, + }); + elementMaskCtx.fillStyle = "white"; + elementMaskCtx.fill(path2d); + } else { + body.drawWithFeather({ + resolvedParams: mask.params, + ctx: elementMaskCtx, + width: Math.round(transform.width), + height: Math.round(transform.height), + feather: mask.params.feather, + }); + } + break; } drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform }); @@ -455,50 +468,38 @@ function buildMaskArtifacts({ draw: drawMask, }); - const hasStroke = - mask.params.strokeWidth > 0 && - (definition.renderer.renderStroke || - definition.renderer.buildStrokePath || - definition.renderer.buildPath); + const stroke = definition.renderer.stroke; + const hasStroke = mask.params.strokeWidth > 0 && Boolean(stroke); let strokeLayer: FrameItemDescriptor | null = null; - if (hasStroke) { + if (hasStroke && stroke) { const strokeTextureId = `${path}:mask-stroke`; - const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`; + const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:stroke=${stroke.kind}`; const drawStroke: TextureCanvasDrawFn = (ctx) => { - const strokeCanvas = createOffscreenCanvas({ + const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({ width: Math.round(transform.width), height: Math.round(transform.height), }); - const strokeCtx = strokeCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!strokeCtx) return; - if (definition.renderer.renderStroke) { - definition.renderer.renderStroke({ - resolvedParams: mask.params, - ctx: strokeCtx, - width: transform.width, - height: transform.height, - }); - } else { - const strokePath = - definition.renderer.buildStrokePath?.({ + switch (stroke.kind) { + case "renderStroke": + stroke.renderStroke({ resolvedParams: mask.params, + ctx: strokeCtx, width: transform.width, height: transform.height, - }) ?? - definition.renderer.buildPath?.({ + }); + break; + case "strokeFromPath": { + const strokePath = stroke.buildStrokePath({ resolvedParams: mask.params, width: transform.width, height: transform.height, - }) ?? - null; - if (!strokePath) return; - strokeCtx.strokeStyle = mask.params.strokeColor; - strokeCtx.lineWidth = mask.params.strokeWidth; - strokeCtx.stroke(strokePath); + }); + strokeCtx.strokeStyle = mask.params.strokeColor; + strokeCtx.lineWidth = mask.params.strokeWidth; + strokeCtx.stroke(strokePath); + break; + } } drawTransformedCanvas({ ctx, source: strokeCanvas, transform }); diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts index c4cacde0..cbfb1a31 100644 --- a/apps/web/src/services/renderer/effect-preview.ts +++ b/apps/web/src/services/renderer/effect-preview.ts @@ -1,4 +1,4 @@ -import { createOffscreenCanvas } from "./canvas-utils"; +import { createCanvasSurface } from "./canvas-utils"; import { effectsRegistry, resolveEffectPasses } from "@/effects"; import { buildDefaultParamValues } from "@/params/registry"; import type { ParamValues } from "@/params"; @@ -8,7 +8,7 @@ const PREVIEW_SIZE = 160; const PREVIEW_IMAGE_PATH = "/effects/preview.jpg"; class EffectPreviewService { - private testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; + private testSourceCanvas: OffscreenCanvas | null = null; private previewImageElement: HTMLImageElement | null = null; private onReadyCallbacks = new Set<() => void>(); @@ -98,7 +98,7 @@ class EffectPreviewService { }: { width: number; height: number; - }): OffscreenCanvas | HTMLCanvasElement | null { + }): OffscreenCanvas | null { const isImageReady = this.previewImageElement?.complete && (this.previewImageElement.naturalWidth ?? 0) > 0; @@ -106,15 +106,8 @@ class EffectPreviewService { return null; } - const canvas = createOffscreenCanvas({ width, height }); - const ctx = canvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!ctx) { - throw new Error("failed to get 2d context for test source"); - } - ctx.drawImage(this.previewImageElement, 0, 0, width, height); + const { canvas, context } = createCanvasSurface({ width, height }); + context.drawImage(this.previewImageElement, 0, 0, width, height); return canvas; } @@ -124,7 +117,7 @@ class EffectPreviewService { }: { width: number; height: number; - }): CanvasImageSource | null { + }): OffscreenCanvas | null { if ( !this.testSourceCanvas || this.testSourceCanvas.width !== width || @@ -141,17 +134,17 @@ class EffectPreviewService { height, passes, }: { - source: CanvasImageSource; + source: OffscreenCanvas; width: number; height: number; passes: ReturnType<typeof resolveEffectPasses>; - }): OffscreenCanvas | HTMLCanvasElement { + }): OffscreenCanvas { return gpuRenderer.applyEffect({ source, width, height, passes, - }) as OffscreenCanvas | HTMLCanvasElement; + }); } } diff --git a/apps/web/src/services/renderer/gpu-renderer.ts b/apps/web/src/services/renderer/gpu-renderer.ts index c61b6b98..c1be3f1a 100644 --- a/apps/web/src/services/renderer/gpu-renderer.ts +++ b/apps/web/src/services/renderer/gpu-renderer.ts @@ -34,23 +34,17 @@ export const gpuRenderer = { height, passes, }: { - source: CanvasImageSource; + source: OffscreenCanvas; width: number; height: number; passes: EffectPass[]; - }): CanvasImageSource { + }): OffscreenCanvas { if (passes.length === 0 || !gpuAvailable) { return source; } - const sourceCanvas = ensureOffscreenCanvas({ - source, - width, - height, - label: "effect source", - }); return applyEffectPasses({ - source: sourceCanvas, + source, width, height, passes: serializeEffectPasses(passes), @@ -63,23 +57,17 @@ export const gpuRenderer = { height, feather, }: { - maskCanvas: CanvasImageSource; + maskCanvas: OffscreenCanvas; width: number; height: number; feather: number; - }): CanvasImageSource { + }): OffscreenCanvas { if (!gpuAvailable) { return maskCanvas; } - const sourceCanvas = ensureOffscreenCanvas({ - source: maskCanvas, - width, - height, - label: "mask source", - }); return applyMaskFeatherWasm({ - mask: sourceCanvas, + mask: maskCanvas, width, height, feather, @@ -87,35 +75,6 @@ export const gpuRenderer = { }, }; -function ensureOffscreenCanvas({ - source, - width, - height, - label, -}: { - source: CanvasImageSource; - width: number; - height: number; - label: string; -}): OffscreenCanvas { - if (source instanceof OffscreenCanvas) { - return source; - } - - if (typeof OffscreenCanvas === "undefined") { - throw new Error(`OffscreenCanvas is required for the GPU ${label}`); - } - - const canvas = new OffscreenCanvas(width, height); - const context = canvas.getContext("2d"); - if (!context) { - throw new Error(`Failed to get 2d context for the GPU ${label}`); - } - context.clearRect(0, 0, width, height); - context.drawImage(source, 0, 0, width, height); - return canvas; -} - function serializeEffectPasses(passes: EffectPass[]) { return passes.map((pass) => ({ shader: pass.shader, diff --git a/apps/web/src/services/renderer/mask-feather.ts b/apps/web/src/services/renderer/mask-feather.ts index 4ea7eb2b..dde4decc 100644 --- a/apps/web/src/services/renderer/mask-feather.ts +++ b/apps/web/src/services/renderer/mask-feather.ts @@ -6,15 +6,15 @@ export function applyMaskFeather({ height, feather, }: { - maskCanvas: CanvasImageSource; + maskCanvas: OffscreenCanvas; width: number; height: number; feather: number; -}): OffscreenCanvas | HTMLCanvasElement { +}): OffscreenCanvas { return gpuRenderer.applyMaskFeather({ maskCanvas, width, height, feather, - }) as OffscreenCanvas | HTMLCanvasElement; + }); } diff --git a/apps/web/src/services/renderer/nodes/graphic-node.ts b/apps/web/src/services/renderer/nodes/graphic-node.ts index 2d022510..798b56a0 100644 --- a/apps/web/src/services/renderer/nodes/graphic-node.ts +++ b/apps/web/src/services/renderer/nodes/graphic-node.ts @@ -1,4 +1,4 @@ -import { createOffscreenCanvas } from "../canvas-utils"; +import { createCanvasSurface } from "../canvas-utils"; import { DEFAULT_GRAPHIC_SOURCE_SIZE, getGraphicDefinition, @@ -25,7 +25,7 @@ export class GraphicNode extends VisualNode< ResolvedGraphicNodeState > { private cachedKey: string | null = null; - private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null; + private cachedSource: OffscreenCanvas | null = null; constructor(params: GraphicNodeParams) { super(params); @@ -36,7 +36,7 @@ export class GraphicNode extends VisualNode< resolvedParams, }: { resolvedParams: ParamValues; - }): OffscreenCanvas | HTMLCanvasElement | null { + }): OffscreenCanvas { const definition = getGraphicDefinition({ definitionId: this.params.definitionId, }); @@ -48,20 +48,13 @@ export class GraphicNode extends VisualNode< return this.cachedSource; } - const canvas = createOffscreenCanvas({ + const { canvas, context } = createCanvasSurface({ width: DEFAULT_GRAPHIC_SOURCE_SIZE, height: DEFAULT_GRAPHIC_SOURCE_SIZE, }); - const ctx = canvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!ctx) { - return null; - } definition.render({ - ctx, + ctx: context, params: resolvedParams, width: DEFAULT_GRAPHIC_SOURCE_SIZE, height: DEFAULT_GRAPHIC_SOURCE_SIZE, diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index d8c56803..4f475a2d 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -17,10 +17,13 @@ export interface CachedImageSource { const imageSourceCache = new Map<string, Promise<CachedImageSource>>(); -export function loadImageSource( - url: string, - maxSourceSize?: number, -): Promise<CachedImageSource> { +export function loadImageSource({ + url, + maxSourceSize, +}: { + url: string; + maxSourceSize?: number; +}): Promise<CachedImageSource> { const cacheKey = `${url}::${maxSourceSize ?? "full"}`; const cached = imageSourceCache.get(cacheKey); diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 7bdf135c..6635a031 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -1,11 +1,14 @@ import { BaseNode } from "./base-node"; import type { TextElement } from "@/timeline"; import type { EffectPass } from "@/effects/types"; -import type { Transform } from "@/rendering"; +import type { BlendMode, Transform } from "@/rendering"; import { drawMeasuredTextLayout } from "@/text/primitives"; import type { MeasuredTextElement } from "@/text/measure-element"; export type TextNodeParams = TextElement & { + transform: Transform; + opacity: number; + blendMode?: BlendMode; canvasCenter: { x: number; y: number }; canvasHeight: number; textBaseline?: CanvasTextBaseline; diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 809f7103..5335de2d 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -1,4 +1,4 @@ -import { mediaTimeToSeconds } from "opencut-wasm"; +import { mediaTimeToSeconds, roundMediaTime } from "@/wasm"; import { getElementLocalTime } from "@/animation"; import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel"; import { @@ -13,6 +13,7 @@ import { resolveGraphicElementParamsAtTime, } from "@/graphics"; import { + buildTextBackgroundFromElement, getTextMeasurementContext, measureTextElement, } from "@/text/measure-element"; @@ -204,7 +205,7 @@ async function resolveVideoNode({ const frame = await videoCache.getFrameAt({ mediaId: node.params.mediaId, file: node.params.file, - time: mediaTimeToSeconds({ time: sourceTimeTicks }), + time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }), }); if (!frame) { return null; @@ -235,10 +236,10 @@ async function resolveImageNode({ node: ImageNode; context: ResolveContext; }): Promise<ResolvedVisualSourceNodeState | null> { - const source = await loadImageSource( - node.params.url, - node.params.maxSourceSize, - ); + const source = await loadImageSource({ + url: node.params.url, + maxSourceSize: node.params.maxSourceSize, + }); const visualState = resolveVisualState({ params: node.params, context, @@ -330,6 +331,7 @@ function resolveTextNode({ elementStartTime: node.params.startTime, elementDuration: node.params.duration, }); + const background = buildTextBackgroundFromElement({ element: node.params }); return { transform: resolveTransformAtTime({ @@ -343,13 +345,16 @@ function resolveTextNode({ localTime, }), textColor: resolveColorAtTime({ - baseColor: node.params.color, + baseColor: + typeof node.params.params.color === "string" + ? node.params.params.color + : "#ffffff", animations: node.params.animations, propertyPath: "color", localTime, }), backgroundColor: resolveColorAtTime({ - baseColor: node.params.background.color, + baseColor: background.color, animations: node.params.animations, propertyPath: "background.color", localTime, @@ -421,7 +426,7 @@ async function resolveBackdropSource({ const frame = await videoCache.getFrameAt({ mediaId: node.params.mediaId, file: node.params.file, - time: mediaTimeToSeconds({ time: sourceTimeTicks }), + time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }), }); if (!frame) { return null; @@ -434,7 +439,7 @@ async function resolveBackdropSource({ }; } - const source = await loadImageSource(node.params.url); + const source = await loadImageSource({ url: node.params.url }); return { source: source.source, width: source.width, diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index e79c62d4..431b8269 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -12,6 +12,11 @@ import { EffectLayerNode } from "./nodes/effect-layer-node"; import type { AnyBaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/project/types"; import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/background/blur"; +import { + buildTransformFromParams, + readBlendModeFromParams, + readOpacityFromParams, +} from "@/rendering"; const PREVIEW_MAX_IMAGE_SIZE = 2048; @@ -71,10 +76,10 @@ function buildTrackNodes({ trimStart: element.trimStart, trimEnd: element.trimEnd, retime: element.retime, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], }), @@ -88,10 +93,10 @@ function buildTrackNodes({ timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], ...(isPreview && { @@ -106,6 +111,9 @@ function buildTrackNodes({ nodes.push( new TextNode({ ...element, + transform: buildTransformFromParams({ params: element.params }), + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, canvasHeight: canvasSize.height, textBaseline: "middle", @@ -124,10 +132,10 @@ function buildTrackNodes({ timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], }), ); @@ -142,10 +150,10 @@ function buildTrackNodes({ timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], }), diff --git a/apps/web/src/services/storage/indexeddb-adapter.ts b/apps/web/src/services/storage/indexeddb-adapter.ts index 2df4feaa..4a7498b6 100644 --- a/apps/web/src/services/storage/indexeddb-adapter.ts +++ b/apps/web/src/services/storage/indexeddb-adapter.ts @@ -5,7 +5,15 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> { private storeName: string; private version: number; - constructor(dbName: string, storeName: string, version = 1) { + constructor({ + dbName, + storeName, + version = 1, + }: { + dbName: string; + storeName: string; + version?: number; + }) { this.dbName = dbName; this.storeName = storeName; this.version = version; @@ -39,7 +47,13 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> { }); } - async set(key: string, value: T): Promise<void> { + async set({ + key, + value, + }: { + key: string; + value: T; + }): Promise<void> { const db = await this.getDB(); const transaction = db.transaction([this.storeName], "readwrite"); const store = transaction.objectStore(this.storeName); diff --git a/apps/web/src/services/storage/migrations/AGENTS.md b/apps/web/src/services/storage/migrations/AGENTS.md new file mode 100644 index 00000000..5aed00a1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/AGENTS.md @@ -0,0 +1,10 @@ +# Agents.md + +## Migration Data Policy + +Migrations are additive only. + +- Do not delete, rename, or replace persisted data in a migration. +- When a new storage shape is needed, add the new fields alongside the old fields. +- Runtime code may prefer or ignore old fields, but the migration must preserve them for future down migrations. +- If old data should ever be removed, that is a separate explicit cleanup task, not part of a version migration.
\ No newline at end of file diff --git a/apps/web/src/services/storage/migrations/__tests__/helpers.ts b/apps/web/src/services/storage/migrations/__tests__/helpers.ts new file mode 100644 index 00000000..381cb935 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/helpers.ts @@ -0,0 +1,39 @@ +/** + * Type-safe accessors for navigating migration output. + * + * Migrations input/output `Record<string, unknown>` because they handle + * potentially malformed data from older versions. Tests need to inspect + * specific properties of the migrated result without using type assertions. + * + * These helpers narrow through type guards (Array.isArray + a record + * predicate), so the file contains no unsafe assertions despite turning + * unknown into concrete shapes. + */ + +function describe(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function asRecord(value: unknown): Record<string, unknown> { + if (!isRecord(value)) { + throw new Error(`Expected record, got ${describe(value)}`); + } + return value; +} + +export function asArray(value: unknown): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`Expected array, got ${describe(value)}`); + } + return value; +} + +export function asRecordArray(value: unknown): Record<string, unknown>[] { + return asArray(value).map(asRecord); +} diff --git a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts index 2cd67da8..73b64fed 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts @@ -8,6 +8,7 @@ import { v0ProjectWithMetadata, v1Project, } from "./fixtures"; +import { asArray, asRecord, asRecordArray } from "./helpers"; describe("V0 to V1 Migration", () => { const fixedDate = new Date("2024-06-01T12:00:00.000Z"); @@ -22,7 +23,7 @@ describe("V0 to V1 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(1); expect(Array.isArray(result.project.scenes)).toBe(true); - expect((result.project.scenes as unknown[]).length).toBe(1); + expect(asArray(result.project.scenes).length).toBe(1); expect(result.project.currentSceneId).toBeDefined(); }); @@ -32,7 +33,7 @@ describe("V0 to V1 Migration", () => { options: { now: fixedDate }, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; expect(mainScene.isMain).toBe(true); @@ -48,7 +49,7 @@ describe("V0 to V1 Migration", () => { options: { now: fixedDate }, }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.updatedAt).toBe(fixedDate.toISOString()); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts index 0840ac76..6670c479 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts @@ -11,6 +11,7 @@ import { v1ProjectWithMultipleScenes, v2Project, } from "./fixtures"; +import { asRecord, asRecordArray } from "./helpers"; const DEFAULT_FPS = 30; const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; @@ -25,7 +26,7 @@ describe("V1 to V2 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(2); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.id).toBe(v1Project.id); expect(metadata.name).toBe(v1Project.name); expect(typeof metadata.createdAt).toBe("string"); @@ -35,7 +36,7 @@ describe("V1 to V2 Migration", () => { test("creates settings object from flat properties", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(v1Project.fps); expect(settings.canvasSize).toEqual(v1Project.canvasSize); expect(settings.originalCanvasSize).toBe(null); @@ -44,8 +45,8 @@ describe("V1 to V2 Migration", () => { test("converts color background correctly", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.type).toBe("color"); expect(background.color).toBe(v1Project.backgroundColor); }); @@ -58,8 +59,8 @@ describe("V1 to V2 Migration", () => { }; const result = transformProjectV1ToV2({ project: projectWithBlur }); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.type).toBe("blur"); expect(background.blurIntensity).toBe(30); }); @@ -67,7 +68,7 @@ describe("V1 to V2 Migration", () => { test("applies legacy bookmarks to main scene", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes.find((s) => s.isMain === true); expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks); }); @@ -77,7 +78,7 @@ describe("V1 to V2 Migration", () => { project: v1ProjectWithMultipleScenes, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const introScene = scenes.find((s) => s.name === "Intro"); expect(introScene?.bookmarks).toEqual([1.0]); }); @@ -102,7 +103,7 @@ describe("V1 to V2 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(DEFAULT_FPS); expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); }); @@ -115,11 +116,11 @@ describe("V1 to V2 Migration", () => { }; const result = transformProjectV1ToV2({ project: minimalProject }); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(DEFAULT_FPS); expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); - const background = settings.background as Record<string, unknown>; + const background = asRecord(settings.background); expect(background.type).toBe("color"); expect(background.color).toBe(DEFAULT_BACKGROUND_COLOR); }); @@ -135,8 +136,8 @@ describe("V1 to V2 Migration", () => { project: projectWithBlurNoIntensity, }); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe(DEFAULT_BACKGROUND_BLUR_INTENSITY); }); @@ -183,9 +184,9 @@ describe("V1 to V2 Migration", () => { project: projectWithTracks, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; - const tracks = mainScene.tracks as Array<Record<string, unknown>>; + const tracks = asRecordArray(mainScene.tracks); expect(tracks.length).toBe(1); expect(tracks[0].name).toBe("Existing Track"); }); @@ -240,9 +241,9 @@ describe("V1 to V2 Migration", () => { context, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; - const tracks = mainScene.tracks as Array<Record<string, unknown>>; + const tracks = asRecordArray(mainScene.tracks); expect(Array.isArray(tracks)).toBe(true); expect(tracks).toHaveLength(1); expect(tracks[0].type).toBe("video"); @@ -302,9 +303,9 @@ describe("V1 to V2 Migration", () => { }); expect(result.skipped).toBe(false); - const scenes = result.project.scenes as Array<Record<string, unknown>>; - const tracks = scenes[0].tracks as Array<Record<string, unknown>>; - const elements = tracks[0].elements as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecordArray(scenes[0].tracks); + const elements = asRecordArray(tracks[0].elements); const textElement = elements[0]; expect(textElement.opacity).toBe(0.5); expect(textElement.transform).toEqual({ diff --git a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts index 122af2cb..1af445ac 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV15ToV16 } from "../transformers/v15-to-v16";
+import { asRecordArray } from "./helpers";
describe("V15 to V16 Migration", () => {
test("renames sticker tracks to graphic tracks", () => {
@@ -61,10 +62,10 @@ describe("V15 to V16 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(16);
- expect(
- (result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0]
- .tracks[0].type,
- ).toBe("graphic");
+ const firstTrack = asRecordArray(
+ asRecordArray(result.project.scenes)[0].tracks,
+ )[0];
+ expect(firstTrack.type).toBe("graphic");
});
test("skips projects already on v16", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts index 352ca762..10a792ca 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV16ToV17 } from "../transformers/v16-to-v17";
+import { asRecord, asRecordArray } from "./helpers";
describe("V16 to V17 Migration", () => {
test("adds center stroke alignment to masks that do not have it", () => {
@@ -97,13 +98,13 @@ describe("V16 to V17 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(17);
- const migratedMasks = (
- ((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record<string, unknown> }> }> }> }>)[0]
- .tracks[0].elements[0].masks)
- );
+ const firstScene = asRecordArray(result.project.scenes)[0];
+ const firstTrack = asRecordArray(firstScene.tracks)[0];
+ const firstElement = asRecordArray(firstTrack.elements)[0];
+ const migratedMasks = asRecordArray(firstElement.masks);
- expect(migratedMasks[0].params.strokeAlign).toBe("center");
- expect(migratedMasks[1].params.strokeAlign).toBe("outside");
+ expect(asRecord(migratedMasks[0].params).strokeAlign).toBe("center");
+ expect(asRecord(migratedMasks[1].params).strokeAlign).toBe("outside");
});
test("skips projects already on v17", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts index 0f679b6d..0cb30f2b 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV18ToV19 } from "../transformers/v18-to-v19";
+import { asRecord } from "./helpers";
describe("V18 to V19 Migration", () => {
test("adds canvas size mode and empty remembered custom size defaults", () => {
@@ -26,15 +27,13 @@ describe("V18 to V19 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(19);
- expect(
- (result.project.settings as Record<string, unknown>).canvasSizeMode,
- ).toBe("preset");
- expect(
- (result.project.settings as Record<string, unknown>).lastCustomCanvasSize,
- ).toBeNull();
- expect(
- (result.project.settings as Record<string, unknown>).originalCanvasSize,
- ).toEqual({ width: 1920, height: 1080 });
+ const settings = asRecord(result.project.settings);
+ expect(settings.canvasSizeMode).toBe("preset");
+ expect(settings.lastCustomCanvasSize).toBeNull();
+ expect(settings.originalCanvasSize).toEqual({
+ width: 1920,
+ height: 1080,
+ });
});
test("skips projects already on v19", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts b/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts index 0e0cfbbe..b1bfe86f 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV19ToV20 } from "../transformers/v19-to-v20"; +import { asRecordArray } from "./helpers"; describe("V19 to V20 Migration", () => { test("backfills source audio enabled state on video elements", () => { @@ -82,19 +83,16 @@ describe("V19 to V20 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(20); - expect( - ((result.project.scenes as Array<Record<string, unknown>>)[0] - .tracks as Array<Record<string, unknown>>)[0].elements, - ).toEqual([ + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecordArray(scene.tracks); + expect(tracks[0].elements).toEqual([ expect.objectContaining({ id: "video-1", isSourceAudioEnabled: true, }), ]); expect( - (((result.project.scenes as Array<Record<string, unknown>>)[0] - .tracks as Array<Record<string, unknown>>)[1] - .elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled, + asRecordArray(tracks[1].elements)[0].isSourceAudioEnabled, ).toBeUndefined(); }); @@ -120,11 +118,10 @@ describe("V19 to V20 Migration", () => { }, }); - expect( - (((result.project.scenes as Array<Record<string, unknown>>)[0] - .tracks as Array<Record<string, unknown>>)[0] - .elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled, - ).toBe(false); + const scene = asRecordArray(result.project.scenes)[0]; + const track = asRecordArray(scene.tracks)[0]; + const element = asRecordArray(track.elements)[0]; + expect(element.isSourceAudioEnabled).toBe(false); }); test("skips projects already on v20", () => { diff --git a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts index ba8b3bfb..d18b4dd4 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts @@ -8,6 +8,7 @@ import { v2ProjectWithBlurBackground, v3Project, } from "./fixtures"; +import { asRecord } from "./helpers"; describe("V2 to V3 Migration", () => { describe("transformProjectV2ToV3", () => { @@ -17,14 +18,14 @@ describe("V2 to V3 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(3); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(typeof metadata.duration).toBe("number"); }); test("calculates duration from scene tracks", () => { const result = transformProjectV2ToV3({ project: v2Project }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); // v2Project has a video element with duration 15.5 and a text element at startTime 2 with duration 5 // Total duration should be max(15.5, 2+5) = 15.5 expect(metadata.duration).toBe(15.5); @@ -36,7 +37,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); // v2ProjectWithBlurBackground has a video with duration 30 expect(metadata.duration).toBe(30); }); @@ -45,7 +46,7 @@ describe("V2 to V3 Migration", () => { const result = transformProjectV2ToV3({ project: v2ProjectEmptyScenes }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -55,7 +56,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -91,7 +92,7 @@ describe("V2 to V3 Migration", () => { test("preserves existing metadata fields", () => { const result = transformProjectV2ToV3({ project: v2Project }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.id).toBe(v2Project.metadata.id); expect(metadata.name).toBe(v2Project.metadata.name); expect(metadata.thumbnail).toBe(v2Project.metadata.thumbnail); @@ -122,7 +123,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -156,7 +157,7 @@ describe("V2 to V3 Migration", () => { }; const result = transformProjectV2ToV3({ project: multiSceneProject }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); // Duration is from main scene only, not sum of all scenes expect(metadata.duration).toBe(10); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts b/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts index 3d77a775..2737bfc3 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV20ToV21 } from "../transformers/v20-to-v21"; +import { asRecord } from "./helpers"; const LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY = 50; @@ -27,8 +28,8 @@ describe("V20 to V21 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(21); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe(500); }); @@ -45,8 +46,8 @@ describe("V20 to V21 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe( LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY, ); @@ -65,7 +66,7 @@ describe("V20 to V21 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.background).toEqual({ type: "color", color: "#000000" }); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts index 18b815b0..76d9c0e1 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV21ToV22 } from "../transformers/v21-to-v22"; +import { asRecord, asRecordArray } from "./helpers"; describe("V21 to V22 Migration", () => { test("migrates legacy animation channels to bindings and component channels", () => { @@ -77,12 +78,12 @@ describe("V21 to V22 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(22); - const scenes = result.project.scenes as Array<Record<string, unknown>>; - const tracks = scenes[0].tracks as Array<Record<string, unknown>>; - const elements = tracks[0].elements as Array<Record<string, unknown>>; - const animations = elements[0].animations as Record<string, unknown>; - const bindings = animations.bindings as Record<string, Record<string, unknown>>; - const channels = animations.channels as Record<string, Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecordArray(scenes[0].tracks); + const elements = asRecordArray(tracks[0].elements); + const animations = asRecord(elements[0].animations); + const bindings = asRecord(animations.bindings); + const channels = asRecord(animations.channels); expect(bindings.opacity).toEqual({ path: "opacity", diff --git a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts index 5b764b48..e229ecbc 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV22ToV23 } from "../transformers/v22-to-v23"; +import { asRecord, asRecordArray } from "./helpers"; describe("V22 to V23 Migration", () => { test("converts project time values from seconds to ticks and fps to a frame-rate object", () => { @@ -101,20 +102,17 @@ describe("V22 to V23 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(23); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(1_860_000); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toEqual({ numerator: 30_000, denominator: 1_001 }); - const timelineViewState = result.project.timelineViewState as Record< - string, - unknown - >; + const timelineViewState = asRecord(result.project.timelineViewState); expect(timelineViewState.playheadTime).toBe(150_000); expect(timelineViewState.scrollLeft).toBe(120); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const scene = scenes[0]; expect(scene.bookmarks).toEqual([ { @@ -126,8 +124,8 @@ describe("V22 to V23 Migration", () => { { time: 540_000 }, ]); - const tracks = scene.tracks as Array<Record<string, unknown>>; - const elements = tracks[0].elements as Array<Record<string, unknown>>; + const tracks = asRecordArray(scene.tracks); + const elements = asRecordArray(tracks[0].elements); const element = elements[0]; expect(element.startTime).toBe(150_000); expect(element.duration).toBe(660_000); @@ -135,8 +133,8 @@ describe("V22 to V23 Migration", () => { expect(element.trimEnd).toBe(60_000); expect(element.sourceDuration).toBe(750_000); - const animations = element.animations as Record<string, unknown>; - const channels = animations.channels as Record<string, Record<string, unknown>>; + const animations = asRecord(element.animations); + const channels = asRecord(animations.channels); expect(channels["opacity:value"]).toEqual({ kind: "scalar", keys: [ diff --git a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts index 730d958d..5e16e886 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV26ToV27 } from "../transformers/v26-to-v27"; +import { asRecord, asRecordArray } from "./helpers"; describe("V26 to V27 Migration", () => { test("converts custom mask paths from JSON strings to typed point arrays", () => { @@ -59,13 +60,13 @@ describe("V26 to V27 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(27); - const scenes = result.project.scenes as Array<Record<string, unknown>>; - const tracks = scenes[0].tracks as Record<string, unknown>; - const mainTrack = tracks.main as Record<string, unknown>; - const elements = mainTrack.elements as Array<Record<string, unknown>>; - const masks = elements[0].masks as Array<Record<string, unknown>>; - const customParams = masks[0].params as Record<string, unknown>; - const rectangleParams = masks[1].params as Record<string, unknown>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecord(scenes[0].tracks); + const mainTrack = asRecord(tracks.main); + const elements = asRecordArray(mainTrack.elements); + const masks = asRecordArray(elements[0].masks); + const customParams = asRecord(masks[0].params); + const rectangleParams = asRecord(masks[1].params); expect(customParams.path).toEqual([ { diff --git a/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts new file mode 100644 index 00000000..cb09e07d --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV27ToV28 } from "../transformers/v27-to-v28"; +import { asRecord, asRecordArray } from "./helpers"; + +describe("V27 to V28 Migration", () => { + test("rounds persisted media-time floats back to integer ticks", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v27-float-time", + version: 27, + metadata: { + id: "project-v27-float-time", + name: "Project", + duration: 2_152_466.3677130044, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + settings: { + fps: { numerator: 30, denominator: 1 }, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + timelineViewState: { + zoomLevel: 1.25, + scrollLeft: 120, + playheadTime: 301_234.8, + }, + scenes: [ + { + id: "scene-1", + name: "Scene 1", + isMain: true, + bookmarks: [ + { + time: 120_000.4, + duration: 60_000.6, + note: "Marker", + color: "#ff0000", + }, + ], + tracks: { + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [ + { + id: "element-1", + type: "video", + name: "Clip", + startTime: 300_000.49, + duration: 2_152_466.3677130044, + trimStart: 30_000.2, + trimEnd: 15_000.7, + sourceDuration: 2_197_467.6, + mediaId: "media-1", + transform: { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + animations: { + channels: { + opacity: { + kind: "scalar", + keys: [ + { + id: "key-1", + time: 1_000.6, + value: 1, + segmentToNext: "bezier", + tangentMode: "flat", + leftHandle: { dt: -120.6, dv: 0 }, + rightHandle: { dt: 60.4, dv: 0 }, + }, + ], + }, + }, + }, + }, + ], + }, + overlay: [], + audio: [], + }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(28); + + const metadata = asRecord(result.project.metadata); + expect(metadata.duration).toBe(2_152_466); + + const timelineViewState = asRecord(result.project.timelineViewState); + expect(timelineViewState.playheadTime).toBe(301_235); + expect(timelineViewState.zoomLevel).toBe(1.25); + expect(timelineViewState.scrollLeft).toBe(120); + + const scenes = asRecordArray(result.project.scenes); + const scene = scenes[0]; + expect(scene.bookmarks).toEqual([ + { + time: 120_000, + duration: 60_001, + note: "Marker", + color: "#ff0000", + }, + ]); + + const tracks = asRecord(scene.tracks); + const mainTrack = asRecord(tracks.main); + const elements = asRecordArray(mainTrack.elements); + const element = elements[0]; + expect(element.startTime).toBe(300_000); + expect(element.duration).toBe(2_152_466); + expect(element.trimStart).toBe(30_000); + expect(element.trimEnd).toBe(15_001); + expect(element.sourceDuration).toBe(2_197_468); + + const animations = asRecord(element.animations); + const channels = asRecord(animations.channels); + const opacityChannel = asRecord(channels.opacity); + expect(opacityChannel.keys).toEqual([ + { + id: "key-1", + time: 1_001, + value: 1, + segmentToNext: "bezier", + tangentMode: "flat", + leftHandle: { dt: -121, dv: 0 }, + rightHandle: { dt: 60, dv: 0 }, + }, + ]); + }); + + test("keeps already-integer media-time values unchanged", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v27-integer-time", + version: 27, + metadata: { + id: "project-v27-integer-time", + name: "Project", + duration: 120_000, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + timelineViewState: { + zoomLevel: 2, + scrollLeft: 300, + playheadTime: 30_000, + }, + scenes: [ + { + id: "scene-1", + bookmarks: [{ time: 60_000, duration: 15_000 }], + tracks: { + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [ + { + id: "element-1", + type: "video", + name: "Clip", + startTime: 10_000, + duration: 20_000, + trimStart: 1_000, + trimEnd: 2_000, + sourceDuration: 23_000, + mediaId: "media-1", + transform: { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + overlay: [], + audio: [], + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(28); + + const metadata = asRecord(result.project.metadata); + expect(metadata.duration).toBe(120_000); + + const timelineViewState = asRecord(result.project.timelineViewState); + expect(timelineViewState).toEqual({ + zoomLevel: 2, + scrollLeft: 300, + playheadTime: 30_000, + }); + + const scenes = asRecordArray(result.project.scenes); + const scene = scenes[0]; + expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]); + + const tracks = asRecord(scene.tracks); + const mainTrack = asRecord(tracks.main); + const element = asRecordArray(mainTrack.elements)[0]; + expect(element.startTime).toBe(10_000); + expect(element.duration).toBe(20_000); + expect(element.trimStart).toBe(1_000); + expect(element.trimEnd).toBe(2_000); + expect(element.sourceDuration).toBe(23_000); + }); + + test("skips projects already on v28", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v28", + version: 28, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v28"); + }); + + test("skips projects not on v27", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v26", + version: 26, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v27"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts b/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts new file mode 100644 index 00000000..38a806e1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV28ToV29 } from "../transformers/v28-to-v29"; +import { asRecord, asRecordArray } from "./helpers"; + +describe("V28 to V29 Migration", () => { + test("copies built-in element fields into params without deleting source fields", () => { + const result = transformProjectV28ToV29({ + project: { + id: "project-v28-builtins", + version: 28, + scenes: [ + { + id: "scene-1", + tracks: { + main: { + id: "main", + type: "video", + elements: [ + { + id: "video-1", + type: "video", + name: "Video", + transform: { + position: { x: 12, y: -4 }, + scaleX: 1.25, + scaleY: 0.75, + rotate: 9, + }, + opacity: 0.5, + blendMode: "multiply", + volume: -6, + muted: true, + }, + ], + }, + overlay: [ + { + id: "text-track", + type: "text", + elements: [ + { + id: "text-1", + type: "text", + name: "Text", + content: "Hello", + fontSize: 20, + fontFamily: "Inter", + color: "#eeeeee", + textAlign: "left", + fontWeight: "bold", + fontStyle: "italic", + textDecoration: "underline", + letterSpacing: 1.5, + lineHeight: 1.4, + background: { + enabled: true, + color: "#111111", + paddingX: 10, + paddingY: 12, + }, + transform: { + position: { x: 1, y: 2 }, + scaleX: 1, + scaleY: 1, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + audio: [], + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(29); + + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecord(scene.tracks); + const main = asRecord(tracks.main); + const video = asRecordArray(main.elements)[0]; + expect(video.transform).toEqual({ + position: { x: 12, y: -4 }, + scaleX: 1.25, + scaleY: 0.75, + rotate: 9, + }); + expect(video.opacity).toBe(0.5); + expect(video.volume).toBe(-6); + expect(video.muted).toBe(true); + expect(video.params).toEqual({ + "transform.positionX": 12, + "transform.positionY": -4, + "transform.scaleX": 1.25, + "transform.scaleY": 0.75, + "transform.rotate": 9, + opacity: 0.5, + blendMode: "multiply", + volume: -6, + muted: true, + }); + + const overlay = asRecordArray(tracks.overlay)[0]; + const text = asRecordArray(asRecord(overlay).elements)[0]; + expect(text.content).toBe("Hello"); + expect(text.background).toEqual({ + enabled: true, + color: "#111111", + paddingX: 10, + paddingY: 12, + }); + expect(text.params).toMatchObject({ + content: "Hello", + fontSize: 20, + fontFamily: "Inter", + color: "#eeeeee", + textAlign: "left", + fontWeight: "bold", + fontStyle: "italic", + textDecoration: "underline", + letterSpacing: 1.5, + lineHeight: 1.4, + "background.enabled": true, + "background.color": "#111111", + "background.paddingX": 10, + "background.paddingY": 12, + "transform.positionX": 1, + "transform.positionY": 2, + }); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts new file mode 100644 index 00000000..c68f8dce --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV29ToV30 } from "../transformers/v29-to-v30"; +import { asRecord, asRecordArray } from "./helpers"; + +describe("V29 to V30 Migration", () => { + test("flattens animation bindings and channels by path", () => { + const result = transformProjectV29ToV30({ + project: { + id: "project-v29-animations", + version: 29, + scenes: [ + { + id: "scene-1", + tracks: { + main: { + id: "main", + type: "video", + elements: [ + { + id: "text-1", + type: "text", + animations: { + bindings: { + opacity: { + path: "opacity", + kind: "number", + components: [ + { key: "value", channelId: "opacity:value" }, + ], + }, + color: { + path: "color", + kind: "color", + colorSpace: "srgb-linear", + components: [ + { key: "r", channelId: "color:r" }, + { key: "g", channelId: "color:g" }, + { key: "b", channelId: "color:b" }, + { key: "a", channelId: "color:a" }, + ], + }, + "params.label": { + path: "params.label", + kind: "discrete", + components: [ + { + key: "value", + channelId: "params.label:value", + }, + ], + }, + }, + channels: { + "opacity:value": { + kind: "scalar", + keys: [{ id: "o1", time: 0, value: 0.5 }], + }, + "color:r": { + kind: "scalar", + keys: [{ id: "c1", time: 0, value: 1 }], + }, + "color:g": { + kind: "scalar", + keys: [{ id: "c1", time: 0, value: 0.5 }], + }, + "color:b": { + kind: "scalar", + keys: [{ id: "c1", time: 0, value: 0 }], + }, + "color:a": { + kind: "scalar", + keys: [{ id: "c1", time: 0, value: 1 }], + }, + "params.label:value": { + kind: "discrete", + keys: [{ id: "d1", time: 0, value: "Title" }], + }, + }, + }, + }, + ], + }, + overlay: [], + audio: [], + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(30); + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecord(scene.tracks); + const main = asRecord(tracks.main); + const element = asRecordArray(main.elements)[0]; + const animations = asRecord(element.animations); + expect(animations.bindings).toBeDefined(); + expect(animations.channels).toBeDefined(); + expect(animations).toMatchObject({ + opacity: { + keys: [{ id: "o1", time: 0, value: 0.5 }], + }, + color: { + r: { keys: [{ id: "c1", time: 0, value: 1 }] }, + g: { keys: [{ id: "c1", time: 0, value: 0.5 }] }, + b: { keys: [{ id: "c1", time: 0, value: 0 }] }, + a: { keys: [{ id: "c1", time: 0, value: 1 }] }, + }, + "params.label": { + keys: [{ id: "d1", time: 0, value: "Title" }], + }, + }); + }); + + test("leaves elements without animations unchanged", () => { + const result = transformProjectV29ToV30({ + project: { + id: "project-v29-no-animations", + version: 29, + scenes: [ + { + tracks: { + main: { + elements: [{ id: "image-1", type: "image" }], + }, + }, + }, + ], + }, + }); + + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecord(scene.tracks); + const main = asRecord(tracks.main); + const element = asRecordArray(main.elements)[0]; + expect(element).toEqual({ id: "image-1", type: "image" }); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts index 6c2d6597..e5ad7e34 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV3ToV4 } from "../transformers/v3-to-v4";
import { v3Project } from "./fixtures";
+import { asRecordArray } from "./helpers";
describe("V3 to V4 Migration", () => {
test("normalizes legacy text fontWeight values", () => {
@@ -54,15 +55,9 @@ describe("V3 to V4 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(4);
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.fontWeight).toBe("700");
});
@@ -104,15 +99,9 @@ describe("V3 to V4 Migration", () => { };
const result = transformProjectV3ToV4({ project: projectWithoutTextTrack });
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.iconName).toBe("mdi:home");
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts b/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts new file mode 100644 index 00000000..65a1ffed --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV30ToV31 } from "../transformers/v30-to-v31"; +import { asRecord, asRecordArray } from "./helpers"; + +describe("V30 to V31 Migration", () => { + test("renames custom masks to freeform without changing params", () => { + const params = { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + path: [{ id: "p1", x: 0, y: 0, inX: 0, inY: 0, outX: 0, outY: 0 }], + closed: true, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + }; + + const result = transformProjectV30ToV31({ + project: { + id: "project-v30-freeform", + version: 30, + scenes: [ + { + tracks: { + main: { + elements: [ + { + id: "image-1", + type: "image", + masks: [{ id: "mask-1", type: "custom", params }], + }, + ], + }, + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(31); + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecord(scene.tracks); + const main = asRecord(tracks.main); + const element = asRecordArray(main.elements)[0]; + const mask = asRecordArray(asRecord(element).masks)[0]; + expect(mask).toMatchObject({ + id: "mask-1", + type: "freeform", + params, + }); + expect(asRecord(mask).legacyType).toBeUndefined(); + }); + + test("leaves a type:freeform mask in a v30 project unchanged", () => { + const result = transformProjectV30ToV31({ + project: { + id: "project-v30-already-freeform", + version: 30, + scenes: [ + { + tracks: { + main: { + elements: [ + { + id: "elem-1", + masks: [{ id: "m1", type: "freeform", params: {} }], + }, + ], + }, + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + const scene = asRecordArray(result.project.scenes)[0]; + const main = asRecord(asRecord(scene.tracks).main); + const element = asRecordArray(main.elements)[0]; + expect(asRecordArray(asRecord(element).masks)[0]).toMatchObject({ + id: "m1", + type: "freeform", + }); + }); + + test("leaves elements without a masks array unchanged", () => { + const result = transformProjectV30ToV31({ + project: { + id: "project-v30-no-masks", + version: 30, + scenes: [ + { + tracks: { + main: { + elements: [{ id: "elem-1", type: "video" }], + }, + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + const scene = asRecordArray(result.project.scenes)[0]; + const main = asRecord(asRecord(scene.tracks).main); + const element = asRecordArray(main.elements)[0]; + expect(element).toMatchObject({ id: "elem-1", type: "video" }); + }); + + test("skips a project that is already v31", () => { + const project = { id: "p1", version: 31, scenes: [] }; + const result = transformProjectV30ToV31({ project }); + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v31"); + expect(result.project).toBe(project); + }); + + test("skips a project that is not v30", () => { + const project = { id: "p1", version: 29, scenes: [] }; + const result = transformProjectV30ToV31({ project }); + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v30"); + expect(result.project).toBe(project); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts index 95386137..16e9e7ca 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV4ToV5 } from "../transformers/v4-to-v5";
import { v3Project } from "./fixtures";
+import { asRecordArray } from "./helpers";
describe("V4 to V5 Migration", () => {
test("migrates sticker iconName to stickerId and removes legacy color", () => {
@@ -48,15 +49,9 @@ describe("V4 to V5 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(5);
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.stickerId).toBe("icons:mdi:home");
expect("iconName" in migratedElement).toBe(false);
@@ -101,15 +96,9 @@ describe("V4 to V5 Migration", () => { };
const result = transformProjectV4ToV5({ project: projectWithStickerId });
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.stickerId).toBe("flags:AD");
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts index b05d1837..8fd267fd 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; import { v5Project } from "./fixtures"; +import { asRecordArray } from "./helpers"; describe("V5 to V6 Migration", () => { test("converts number bookmarks to Bookmark objects", async () => { @@ -13,19 +14,13 @@ describe("V5 to V6 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(6); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ + const scenes = asRecordArray(result.project.scenes); + expect(scenes[0].bookmarks).toEqual([ { time: 2.0 }, { time: 5.5 }, { time: 12.0 }, ]); - - const introScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[1]; - expect(introScene.bookmarks).toEqual([]); + expect(scenes[1].bookmarks).toEqual([]); }); test("skips projects that are already v6", () => { @@ -83,9 +78,7 @@ describe("V5 to V6 Migration", () => { }); expect(result.skipped).toBe(false); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; + const mainScene = asRecordArray(result.project.scenes)[0]; expect(mainScene.bookmarks).toEqual([ { time: 1, note: "Intro", color: "#ef4444" }, { time: 5.5, duration: 2 }, diff --git a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts index aa79a6ea..ce8e9bd3 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
+import { asRecord, asRecordArray } from "./helpers";
const v8ProjectWithText = {
id: "project-v8-text",
@@ -70,16 +71,16 @@ describe("V8 to V9 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(9);
- const track = (
- result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
- )[0].tracks[0];
- const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
+ const track = asRecordArray(asRecordArray(result.project.scenes)[0].tracks)[0];
+ const elements = asRecordArray(track.elements);
+ const background0 = asRecord(elements[0].background);
+ const background1 = asRecord(elements[1].background);
- expect(elements[0].background.enabled).toBe(true);
- expect(elements[0].background.color).toBe("#ff0000");
+ expect(background0.enabled).toBe(true);
+ expect(background0.color).toBe("#ff0000");
- expect(elements[1].background.enabled).toBe(false);
- expect(elements[1].background.color).toBe("transparent");
+ expect(background1.enabled).toBe(false);
+ expect(background1.color).toBe("transparent");
});
test("preserves existing background.enabled if already present", () => {
@@ -87,7 +88,7 @@ describe("V8 to V9 Migration", () => { ...v8ProjectWithText,
scenes: [
{
- ...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
+ ...asRecordArray(v8ProjectWithText.scenes)[0],
tracks: [
{
id: "track-text",
@@ -116,10 +117,9 @@ describe("V8 to V9 Migration", () => { const result = transformProjectV8ToV9({ project: projectWithEnabled });
expect(result.skipped).toBe(false);
- const elements = (
- result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
- )[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
- expect(elements[0].background.enabled).toBe(false);
+ const track = asRecordArray(asRecordArray(result.project.scenes)[0].tracks)[0];
+ const elements = asRecordArray(track.elements);
+ expect(asRecord(elements[0].background).enabled).toBe(false);
});
test("skips non-text elements and tracks", () => {
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index b290b713..cbc35bb3 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -26,10 +26,14 @@ import { V23toV24Migration } from "./v23-to-v24"; import { V24toV25Migration } from "./v24-to-v25"; import { V25toV26Migration } from "./v25-to-v26"; import { V26toV27Migration } from "./v26-to-v27"; +import { V27toV28Migration } from "./v27-to-v28"; +import { V28toV29Migration } from "./v28-to-v29"; +import { V29toV30Migration } from "./v29-to-v30"; +import { V30toV31Migration } from "./v30-to-v31"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 27; +export const CURRENT_PROJECT_VERSION = 31; export const migrations = [ new V0toV1Migration(), @@ -59,4 +63,8 @@ export const migrations = [ new V24toV25Migration(), new V25toV26Migration(), new V26toV27Migration(), + new V27toV28Migration(), + new V28toV29Migration(), + new V29toV30Migration(), + new V30toV31Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts index c1ae894f..ace19354 100644 --- a/apps/web/src/services/storage/migrations/transformers/index.ts +++ b/apps/web/src/services/storage/migrations/transformers/index.ts @@ -4,4 +4,5 @@ export { transformProjectV2ToV3 } from "./v2-to-v3"; export { transformProjectV3ToV4 } from "./v3-to-v4"; export { transformProjectV4ToV5 } from "./v4-to-v5"; export { transformProjectV17ToV18 } from "./v17-to-v18"; +export { transformProjectV28ToV29 } from "./v28-to-v29"; export type { MigrationResult, ProjectRecord } from "./types"; diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index 065a6b34..e9e23c4e 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -19,34 +19,6 @@ const EMPTY_V1_TO_V2_CONTEXT: V1ToV2Context = { mediaTypesById: {}, }; -interface LegacyMediaElement { - type: "media"; - mediaId: string; - muted?: boolean; - [key: string]: unknown; -} - -interface LegacyTextElement { - type: "text"; - x: number; - y: number; - rotation: number; - opacity: number; - [key: string]: unknown; -} - -interface LegacyAudioElement { - type: "audio"; - mediaId: string; - [key: string]: unknown; -} - -interface LegacyMediaTrack { - type: "media"; - elements: unknown[]; - [key: string]: unknown; -} - interface V2Transform { scale: number; position: { x: number; y: number }; @@ -315,7 +287,7 @@ function transformTracks({ const isMain = !isFirstVideoTrackFound; isFirstVideoTrackFound = true; const videoTrack = transformMediaTrack({ - track: track as LegacyMediaTrack, + track, context, isMain, }); @@ -343,7 +315,7 @@ function transformMediaTrack({ context, isMain, }: { - track: LegacyMediaTrack; + track: Record<string, unknown>; context: V1ToV2Context; isMain: boolean; }): V2VideoTrack { @@ -354,8 +326,7 @@ function transformMediaTrack({ return null; } - const mediaElement = element as LegacyMediaElement; - const mediaId = getStringValue({ value: mediaElement.mediaId }); + const mediaId = getStringValue({ value: element.mediaId }); if (!mediaId) { return null; } @@ -372,7 +343,7 @@ function transformMediaTrack({ rotate: 0, }; - const muted = mediaElement.muted === true; + const muted = element.muted === true; if (mediaType === "image") { const imageElement: V2ImageElement = { @@ -442,15 +413,14 @@ function transformTextTrack({ return null; } - const textElement = element as LegacyTextElement; - const x = getNumberValue({ value: textElement.x, fallback: 0 }); - const y = getNumberValue({ value: textElement.y, fallback: 0 }); + const x = getNumberValue({ value: element.x, fallback: 0 }); + const y = getNumberValue({ value: element.y, fallback: 0 }); const rotation = getNumberValue({ - value: textElement.rotation, + value: element.rotation, fallback: 0, }); const opacity = getNumberValue({ - value: textElement.opacity, + value: element.opacity, fallback: 1, }); @@ -464,23 +434,23 @@ function transformTextTrack({ id: getStringValue({ value: element.id, fallback: "" }), name: getStringValue({ value: element.name, fallback: "" }), type: "text", - content: getStringValue({ value: textElement.content, fallback: "" }), + content: getStringValue({ value: element.content, fallback: "" }), fontSize: getNumberValue({ - value: textElement.fontSize, + value: element.fontSize, fallback: 16, }), fontFamily: getStringValue({ - value: textElement.fontFamily, + value: element.fontFamily, fallback: "Arial", }), color: getStringValue({ - value: textElement.color, + value: element.color, fallback: "#000000", }), background: { enabled: false, color: getStringValue({ - value: textElement.backgroundColor, + value: element.backgroundColor, fallback: "transparent", }), cornerRadius: 0, @@ -489,22 +459,26 @@ function transformTextTrack({ offsetX: 0, offsetY: 0, }, - textAlign: (getStringValue({ - value: textElement.textAlign, + textAlign: parseEnum({ + value: element.textAlign, + allowed: ["left", "center", "right"] as const, fallback: "left", - }) || "left") as "left" | "center" | "right", - fontWeight: (getStringValue({ - value: textElement.fontWeight, + }), + fontWeight: parseEnum({ + value: element.fontWeight, + allowed: ["normal", "bold"] as const, fallback: "normal", - }) || "normal") as "normal" | "bold", - fontStyle: (getStringValue({ - value: textElement.fontStyle, + }), + fontStyle: parseEnum({ + value: element.fontStyle, + allowed: ["normal", "italic"] as const, fallback: "normal", - }) || "normal") as "normal" | "italic", - textDecoration: (getStringValue({ - value: textElement.textDecoration, + }), + textDecoration: parseEnum({ + value: element.textDecoration, + allowed: ["none", "underline", "line-through"] as const, fallback: "none", - }) || "none") as "none" | "underline" | "line-through", + }), hidden: false, transform, opacity, @@ -538,8 +512,7 @@ function transformAudioTrack({ return null; } - const audioElement = element as LegacyAudioElement; - const mediaId = getStringValue({ value: audioElement.mediaId }); + const mediaId = getStringValue({ value: element.mediaId }); if (!mediaId) { return null; } @@ -762,6 +735,23 @@ function getStringValue({ return fallback; } +function parseEnum<T extends string>({ + value, + allowed, + fallback, +}: { + value: unknown; + allowed: readonly T[]; + fallback: T; +}): T { + for (const candidate of allowed) { + if (value === candidate) { + return candidate; + } + } + return fallback; +} + function normalizeDateString({ value }: { value: unknown }): string { if (value instanceof Date) { return value.toISOString(); diff --git a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts index 89dde38e..6e152adb 100644 --- a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts +++ b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts @@ -94,10 +94,7 @@ function migrateSplitMask({ mask }: { mask: unknown }): unknown { const x = (position - 0.5) * Math.cos(angleRad);
const y = (position - 0.5) * Math.sin(angleRad);
- const { position: _removed, ...restParams } = params as Record<
- string,
- unknown
- > & { position: unknown };
+ const { position: _removed, ...restParams } = params;
return {
...mask,
diff --git a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts index 3b118ee7..ccebb980 100644 --- a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts +++ b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts @@ -1,7 +1,7 @@ import type { MigrationResult, ProjectRecord } from "./types"; import { getProjectId, isRecord } from "./utils"; -interface CustomMaskPathPoint { +interface FreeformPathPoint { id: string; x: number; y: number; @@ -11,35 +11,34 @@ interface CustomMaskPathPoint { outY: number; } -function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { - if (!value || typeof value !== "object") { +function isFreeformPathPoint(value: unknown): value is FreeformPathPoint { + if (!isRecord(value)) { return false; } - const candidate = value as Record<string, unknown>; return ( - typeof candidate.id === "string" && - typeof candidate.x === "number" && - typeof candidate.y === "number" && - typeof candidate.inX === "number" && - typeof candidate.inY === "number" && - typeof candidate.outX === "number" && - typeof candidate.outY === "number" + typeof value.id === "string" && + typeof value.x === "number" && + typeof value.y === "number" && + typeof value.inX === "number" && + typeof value.inY === "number" && + typeof value.outX === "number" && + typeof value.outY === "number" ); } -function parseCustomMaskPath({ +function parseFreeformPath({ path, }: { path: string; -}): CustomMaskPathPoint[] { +}): FreeformPathPoint[] { if (!path) { return []; } try { const parsed = JSON.parse(path); - return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; + return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : []; } catch { return []; } @@ -151,7 +150,7 @@ function migrateMask({ mask }: { mask: unknown }): unknown { ...mask, params: { ...mask.params, - path: parseCustomMaskPath({ path }), + path: parseFreeformPath({ path }), }, }; } diff --git a/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts new file mode 100644 index 00000000..fc8d8432 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts @@ -0,0 +1,228 @@ +import { roundMediaTime } from "@/wasm"; +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV27ToV28({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 28) { + return { project, skipped: true, reason: "already v28" }; + } + if (version !== 27) { + return { project, skipped: true, reason: "not v27" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 28, + }, + skipped: false, + }; +} + +function migrateProject({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const nextProject = { ...project }; + + if (isRecord(project.metadata)) { + nextProject.metadata = migrateTimeFields({ + record: project.metadata, + keys: ["duration"], + }); + } + + if (isRecord(project.timelineViewState)) { + nextProject.timelineViewState = migrateTimeFields({ + record: project.timelineViewState, + keys: ["playheadTime"], + }); + } + + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + + if (Array.isArray(scene.bookmarks)) { + nextScene.bookmarks = scene.bookmarks.map((bookmark) => + migrateBookmark({ bookmark }), + ); + } + + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...tracks }; + + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + + return nextTracks; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) { + return element; + } + + const nextElement = migrateTimeFields({ + record: element, + keys: ["duration", "startTime", "trimStart", "trimEnd", "sourceDuration"], + }); + + if (isRecord(element.animations)) { + nextElement.animations = migrateAnimations({ + animations: element.animations, + }); + } + + return nextElement; +} + +function migrateAnimations({ + animations, +}: { + animations: ProjectRecord; +}): ProjectRecord { + if (!isRecord(animations.channels)) { + return animations; + } + + return { + ...animations, + channels: Object.fromEntries( + Object.entries(animations.channels).map(([channelId, channel]) => [ + channelId, + migrateAnimationChannel({ channel }), + ]), + ), + }; +} + +function migrateAnimationChannel({ channel }: { channel: unknown }): unknown { + if (!isRecord(channel) || !Array.isArray(channel.keys)) { + return channel; + } + + return { + ...channel, + keys: channel.keys.map((keyframe) => migrateAnimationKeyframe({ keyframe })), + }; +} + +function migrateAnimationKeyframe({ + keyframe, +}: { + keyframe: unknown; +}): unknown { + if (!isRecord(keyframe)) { + return keyframe; + } + + const nextKeyframe = migrateTimeFields({ + record: keyframe, + keys: ["time"], + }); + + if (isRecord(keyframe.leftHandle)) { + nextKeyframe.leftHandle = migrateTimeFields({ + record: keyframe.leftHandle, + keys: ["dt"], + }); + } + + if (isRecord(keyframe.rightHandle)) { + nextKeyframe.rightHandle = migrateTimeFields({ + record: keyframe.rightHandle, + keys: ["dt"], + }); + } + + return nextKeyframe; +} + +function migrateBookmark({ bookmark }: { bookmark: unknown }): unknown { + if (!isRecord(bookmark)) { + return bookmark; + } + + return migrateTimeFields({ + record: bookmark, + keys: ["time", "duration"], + }); +} + +function migrateTimeFields({ + record, + keys, +}: { + record: ProjectRecord; + keys: string[]; +}): ProjectRecord { + const nextRecord = { ...record }; + + for (const key of keys) { + if (!(key in record)) { + continue; + } + + nextRecord[key] = normalizeMediaTimeValue({ value: record[key] }); + } + + return nextRecord; +} + +function normalizeMediaTimeValue({ value }: { value: unknown }): unknown { + if (typeof value !== "number" || !Number.isFinite(value)) { + return value; + } + return roundMediaTime({ time: value }); +} diff --git a/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts b/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts new file mode 100644 index 00000000..4ce048f6 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts @@ -0,0 +1,226 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV28ToV29({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 29) { + return { project, skipped: true, reason: "already v29" }; + } + if (version !== 28) { + return { project, skipped: true, reason: "not v28" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 29, + }, + skipped: false, + }; +} + +function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord { + const nextProject = { ...project }; + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...tracks }; + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + return nextTracks; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) { + return element; + } + + const nextElement = { ...element }; + const params: ProjectRecord = isRecord(element.params) + ? { ...element.params } + : {}; + + copyTransformParams({ source: element, params }); + copyPrimitiveParam({ source: element, params, sourceKey: "opacity", paramKey: "opacity" }); + copyPrimitiveParam({ + source: element, + params, + sourceKey: "blendMode", + paramKey: "blendMode", + }); + + if (element.type === "audio" || element.type === "video") { + copyPrimitiveParam({ + source: element, + params, + sourceKey: "volume", + paramKey: "volume", + }); + copyPrimitiveParam({ + source: element, + params, + sourceKey: "muted", + paramKey: "muted", + }); + } + + if (element.type === "text") { + copyTextParams({ source: element, params }); + } + + nextElement.params = params; + return nextElement; +} + +function copyTransformParams({ + source, + params, +}: { + source: ProjectRecord; + params: ProjectRecord; +}): void { + if (!isRecord(source.transform)) { + return; + } + if (isRecord(source.transform.position)) { + copyPrimitiveParam({ + source: source.transform.position, + params, + sourceKey: "x", + paramKey: "transform.positionX", + }); + copyPrimitiveParam({ + source: source.transform.position, + params, + sourceKey: "y", + paramKey: "transform.positionY", + }); + } + copyPrimitiveParam({ + source: source.transform, + params, + sourceKey: "scaleX", + paramKey: "transform.scaleX", + }); + copyPrimitiveParam({ + source: source.transform, + params, + sourceKey: "scaleY", + paramKey: "transform.scaleY", + }); + copyPrimitiveParam({ + source: source.transform, + params, + sourceKey: "rotate", + paramKey: "transform.rotate", + }); +} + +function copyTextParams({ + source, + params, +}: { + source: ProjectRecord; + params: ProjectRecord; +}): void { + for (const key of [ + "content", + "fontSize", + "fontFamily", + "color", + "textAlign", + "fontWeight", + "fontStyle", + "textDecoration", + "letterSpacing", + "lineHeight", + ]) { + copyPrimitiveParam({ source, params, sourceKey: key, paramKey: key }); + } + + if (!isRecord(source.background)) { + return; + } + for (const key of [ + "enabled", + "color", + "cornerRadius", + "paddingX", + "paddingY", + "offsetX", + "offsetY", + ]) { + copyPrimitiveParam({ + source: source.background, + params, + sourceKey: key, + paramKey: `background.${key}`, + }); + } +} + +function copyPrimitiveParam({ + source, + params, + sourceKey, + paramKey, +}: { + source: ProjectRecord; + params: ProjectRecord; + sourceKey: string; + paramKey: string; +}): void { + const value = source[sourceKey]; + if ( + typeof value === "number" || + typeof value === "string" || + typeof value === "boolean" + ) { + params[paramKey] = value; + } +} diff --git a/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts new file mode 100644 index 00000000..f3050742 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts @@ -0,0 +1,138 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV29ToV30({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 30) { + return { project, skipped: true, reason: "already v30" }; + } + if (version !== 29) { + return { project, skipped: true, reason: "not v29" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 30, + }, + skipped: false, + }; +} + +function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord { + const nextProject = { ...project }; + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...tracks }; + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + return nextTracks; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element) || !isRecord(element.animations)) { + return element; + } + + return { + ...element, + animations: migrateAnimations({ animations: element.animations }), + }; +} + +function migrateAnimations({ + animations, +}: { + animations: ProjectRecord; +}): ProjectRecord { + if (!isRecord(animations.bindings) || !isRecord(animations.channels)) { + return animations; + } + + const channels = animations.channels; + const nextAnimations: ProjectRecord = { ...animations }; + for (const binding of Object.values(animations.bindings)) { + if (!isRecord(binding) || typeof binding.path !== "string") { + continue; + } + if (!Array.isArray(binding.components)) { + continue; + } + + const componentEntries = binding.components.flatMap((component) => { + if ( + !isRecord(component) || + typeof component.key !== "string" || + typeof component.channelId !== "string" + ) { + return []; + } + const channel = channels[component.channelId]; + if (!isRecord(channel)) { + return []; + } + return [[component.key, migrateChannel({ channel })] as const]; + }); + if (componentEntries.length === 0) { + continue; + } + + nextAnimations[binding.path] = + componentEntries.length === 1 && componentEntries[0]?.[0] === "value" + ? componentEntries[0][1] + : Object.fromEntries(componentEntries); + } + + return nextAnimations; +} + +function migrateChannel({ channel }: { channel: ProjectRecord }): ProjectRecord { + const { kind: _kind, ...nextChannel } = channel; + return nextChannel; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts index 84d97fd8..7a28134b 100644 --- a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts +++ b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts @@ -1,10 +1,10 @@ import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
-const LEGACY_FONT_WEIGHT_MAP = {
- normal: "400",
- bold: "700",
-} as const;
+const LEGACY_FONT_WEIGHT_MAP = new Map<string, string>([
+ ["normal", "400"],
+ ["bold", "700"],
+]);
const VALID_NUMERIC_FONT_WEIGHTS = new Set([
"100",
@@ -168,10 +168,9 @@ function normalizeFontWeight({ value }: { value: unknown }): unknown { }
const normalized = value.trim().toLowerCase();
- if (normalized in LEGACY_FONT_WEIGHT_MAP) {
- return LEGACY_FONT_WEIGHT_MAP[
- normalized as keyof typeof LEGACY_FONT_WEIGHT_MAP
- ];
+ const mapped = LEGACY_FONT_WEIGHT_MAP.get(normalized);
+ if (mapped !== undefined) {
+ return mapped;
}
if (VALID_NUMERIC_FONT_WEIGHTS.has(normalized)) {
diff --git a/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts b/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts new file mode 100644 index 00000000..512fc921 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts @@ -0,0 +1,100 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV30ToV31({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 31) { + return { project, skipped: true, reason: "already v31" }; + } + if (version !== 30) { + return { project, skipped: true, reason: "not v30" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 31, + }, + skipped: false, + }; +} + +function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord { + const nextProject = { ...project }; + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...tracks }; + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + return nextTracks; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element) || !Array.isArray(element.masks)) { + return element; + } + + return { + ...element, + masks: element.masks.map((mask) => migrateMask({ mask })), + }; +} + +function migrateMask({ mask }: { mask: unknown }): unknown { + if (!isRecord(mask) || mask.type !== "custom") { + return mask; + } + + // "custom" is the only freeform-path mask type; a down-migration can map + // type === "freeform" back to "custom" without any additional marker. + return { + ...mask, + type: "freeform", + }; +} diff --git a/apps/web/src/services/storage/migrations/v27-to-v28.ts b/apps/web/src/services/storage/migrations/v27-to-v28.ts new file mode 100644 index 00000000..aaec6256 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v27-to-v28.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV27ToV28 } from "./transformers/v27-to-v28"; + +export class V27toV28Migration extends StorageMigration { + from = 27; + to = 28; + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + return transformProjectV27ToV28({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v28-to-v29.ts b/apps/web/src/services/storage/migrations/v28-to-v29.ts new file mode 100644 index 00000000..a2e75ec0 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v28-to-v29.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV28ToV29 } from "./transformers/v28-to-v29"; + +export class V28toV29Migration extends StorageMigration { + from = 28; + to = 29; + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + return transformProjectV28ToV29({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v29-to-v30.ts b/apps/web/src/services/storage/migrations/v29-to-v30.ts new file mode 100644 index 00000000..0b9c23b9 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v29-to-v30.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV29ToV30 } from "./transformers/v29-to-v30"; + +export class V29toV30Migration extends StorageMigration { + from = 29; + to = 30; + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + return transformProjectV29ToV30({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v30-to-v31.ts b/apps/web/src/services/storage/migrations/v30-to-v31.ts new file mode 100644 index 00000000..35bf0969 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v30-to-v31.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV30ToV31 } from "./transformers/v30-to-v31"; + +export class V30toV31Migration extends StorageMigration { + from = 30; + to = 31; + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + return transformProjectV30ToV31({ project }); + } +} diff --git a/apps/web/src/services/storage/opfs-adapter.ts b/apps/web/src/services/storage/opfs-adapter.ts index 879223b5..220ee452 100644 --- a/apps/web/src/services/storage/opfs-adapter.ts +++ b/apps/web/src/services/storage/opfs-adapter.ts @@ -27,7 +27,13 @@ export class OPFSAdapter implements StorageAdapter<File> { } } - async set(key: string, file: File): Promise<void> { + async set({ + key, + value: file, + }: { + key: string; + value: File; + }): Promise<void> { const directory = await this.getDirectory(); const fileHandle = await directory.getFileHandle(key, { create: true }); const writable = await fileHandle.createWritable(); diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index 858198eb..e8fc26c0 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -22,12 +22,15 @@ import { runStorageMigrations, } from "@/services/storage/migrations"; import type { Bookmark, SceneTracks, TScene } from "@/timeline"; +import { roundMediaTime } from "@/wasm"; function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { if (!Array.isArray(raw)) return []; return raw .map((item): Bookmark | null => { - if (typeof item === "number") return { time: item }; + if (typeof item === "number") { + return { time: roundMediaTime({ time: item }) }; + } const obj = item as Record<string, unknown>; if ( typeof obj !== "object" || @@ -37,10 +40,12 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { return null; } return { - time: obj.time, + time: roundMediaTime({ time: obj.time }), ...(typeof obj.note === "string" && { note: obj.note }), ...(typeof obj.color === "string" && { color: obj.color }), - ...(typeof obj.duration === "number" && { duration: obj.duration }), + ...(typeof obj.duration === "number" && { + duration: roundMediaTime({ time: obj.duration }), + }), }; }) .filter((b): b is Bookmark => b !== null); @@ -60,17 +65,17 @@ class StorageService { version: 1, }; - this.projectsAdapter = new IndexedDBAdapter<SerializedProject>( - this.config.projectsDb, - "projects", - this.config.version, - ); + this.projectsAdapter = new IndexedDBAdapter<SerializedProject>({ + dbName: this.config.projectsDb, + storeName: "projects", + version: this.config.version, + }); - this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>( - this.config.savedSoundsDb, - "saved-sounds", - this.config.version, - ); + this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>({ + dbName: this.config.savedSoundsDb, + storeName: "saved-sounds", + version: this.config.version, + }); } private async ensureMigrations(): Promise<void> { @@ -86,11 +91,11 @@ class StorageService { } private getProjectMediaAdapters({ projectId }: { projectId: string }) { - const mediaMetadataAdapter = new IndexedDBAdapter<MediaAssetData>( - `${this.config.mediaDb}-${projectId}`, - "media-metadata", - this.config.version, - ); + const mediaMetadataAdapter = new IndexedDBAdapter<MediaAssetData>({ + dbName: `${this.config.mediaDb}-${projectId}`, + storeName: "media-metadata", + version: this.config.version, + }); const mediaAssetsAdapter = new OPFSAdapter(`media-files-${projectId}`); @@ -156,7 +161,10 @@ class StorageService { timelineViewState: project.timelineViewState, }; - await this.projectsAdapter.set(project.metadata.id, serializedProject); + await this.projectsAdapter.set({ + key: project.metadata.id, + value: serializedProject, + }); } async loadProject({ @@ -198,9 +206,11 @@ class StorageService { id: serializedProject.metadata.id, name: serializedProject.metadata.name, thumbnail: serializedProject.metadata.thumbnail, - duration: - serializedProject.metadata.duration ?? - getProjectDurationFromScenes({ scenes }), + duration: roundMediaTime({ + time: + serializedProject.metadata.duration ?? + getProjectDurationFromScenes({ scenes }), + }), createdAt: new Date(serializedProject.metadata.createdAt), updatedAt: new Date(serializedProject.metadata.updatedAt), }, @@ -253,11 +263,13 @@ class StorageService { id: serializedProject.metadata.id, name: serializedProject.metadata.name, thumbnail: serializedProject.metadata.thumbnail, - duration: - serializedProject.metadata.duration ?? - getProjectDurationFromScenes({ - scenes: (serializedProject.scenes ?? []) as unknown as TScene[], - }), + duration: roundMediaTime({ + time: + serializedProject.metadata.duration ?? + getProjectDurationFromScenes({ + scenes: (serializedProject.scenes ?? []) as unknown as TScene[], + }), + }), createdAt: new Date(serializedProject.metadata.createdAt), updatedAt: new Date(serializedProject.metadata.updatedAt), }); @@ -296,8 +308,14 @@ class StorageService { }; try { - await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file); - await mediaMetadataAdapter.set(mediaAsset.id, metadata); + await mediaAssetsAdapter.set({ + key: mediaAsset.id, + value: mediaAsset.file, + }); + await mediaMetadataAdapter.set({ + key: mediaAsset.id, + value: metadata, + }); } catch (error) { try { await mediaAssetsAdapter.remove(mediaAsset.id); @@ -492,7 +510,10 @@ class StorageService { lastModified: new Date().toISOString(), }; - await this.savedSoundsAdapter.set("user-sounds", updatedData); + await this.savedSoundsAdapter.set({ + key: "user-sounds", + value: updatedData, + }); } catch (error) { console.error("Failed to save sound effect:", error); throw error; @@ -508,7 +529,10 @@ class StorageService { lastModified: new Date().toISOString(), }; - await this.savedSoundsAdapter.set("user-sounds", updatedData); + await this.savedSoundsAdapter.set({ + key: "user-sounds", + value: updatedData, + }); } catch (error) { console.error("Failed to remove saved sound:", error); throw error; diff --git a/apps/web/src/services/storage/types.ts b/apps/web/src/services/storage/types.ts index 58c14b49..f20b311a 100644 --- a/apps/web/src/services/storage/types.ts +++ b/apps/web/src/services/storage/types.ts @@ -8,7 +8,7 @@ import type { TScene } from "@/timeline"; export interface StorageAdapter<T> { get(key: string): Promise<T | null>; - set(key: string, value: T): Promise<void>; + set(args: { key: string; value: T }): Promise<void>; remove(key: string): Promise<void>; list(): Promise<string[]>; clear(): Promise<void>; diff --git a/apps/web/src/sounds/sounds-store.ts b/apps/web/src/sounds/sounds-store.ts index b9b3f070..13ced962 100644 --- a/apps/web/src/sounds/sounds-store.ts +++ b/apps/web/src/sounds/sounds-store.ts @@ -4,6 +4,7 @@ import { storageService } from "@/services/storage/service"; import { toast } from "sonner"; import { EditorCore } from "@/core"; import { buildLibraryAudioElement } from "@/timeline/element-utils"; +import { mediaTimeFromSeconds } from "@/wasm"; interface SoundsStore { topSoundEffects: SoundEffect[]; @@ -227,7 +228,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({ const element = buildLibraryAudioElement({ sourceUrl: audioUrl, name: sound.name, - duration: sound.duration, + duration: mediaTimeFromSeconds({ seconds: sound.duration }), startTime: currentTime, buffer, }); diff --git a/apps/web/src/stickers/providers/index.ts b/apps/web/src/stickers/providers/index.ts index ca23963c..56b3f6cb 100644 --- a/apps/web/src/stickers/providers/index.ts +++ b/apps/web/src/stickers/providers/index.ts @@ -1,24 +1,24 @@ -import { stickersRegistry } from "../registry";
-import type { StickerProvider } from "@/stickers/types";
-import { flagsProvider } from "./flags";
-import { logosProvider } from "./logos";
-import { shapesProvider } from "./shapes";
-
-const defaultProviders: StickerProvider[] = [
- logosProvider,
- flagsProvider,
- shapesProvider,
-];
-
-export function registerDefaultStickerProviders({
- providersToRegister = defaultProviders,
-}: {
- providersToRegister?: StickerProvider[];
-} = {}): void {
- for (const provider of providersToRegister) {
- if (stickersRegistry.has(provider.id)) {
- continue;
- }
- stickersRegistry.register(provider.id, provider);
- }
-}
+import { stickersRegistry } from "../registry"; +import type { StickerProvider } from "@/stickers/types"; +import { flagsProvider } from "./flags"; +import { logosProvider } from "./logos"; +import { shapesProvider } from "./shapes"; + +const defaultProviders: StickerProvider[] = [ + logosProvider, + flagsProvider, + shapesProvider, +]; + +export function registerDefaultStickerProviders({ + providersToRegister = defaultProviders, +}: { + providersToRegister?: StickerProvider[]; +} = {}): void { + for (const provider of providersToRegister) { + if (stickersRegistry.has(provider.id)) { + continue; + } + stickersRegistry.register(provider.id, provider); + } +} diff --git a/apps/web/src/subtitles/build-subtitle-text-element.ts b/apps/web/src/subtitles/build-subtitle-text-element.ts index c4ac21ac..87cc6a03 100644 --- a/apps/web/src/subtitles/build-subtitle-text-element.ts +++ b/apps/web/src/subtitles/build-subtitle-text-element.ts @@ -5,8 +5,15 @@ import { setCanvasLetterSpacing, } from "@/text/layout"; import { DEFAULTS } from "@/timeline/defaults"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds } from "@/wasm"; import type { CreateTextElement } from "@/timeline"; +import type { TextBackground } from "@/text/background"; +import type { + TextAlign, + TextDecoration, + TextFontStyle, + TextFontWeight, +} from "@/text/primitives"; import type { SubtitleCue, SubtitleStyleOverrides } from "./types"; const SUBTITLE_MAX_WIDTH_RATIO = 0.8; @@ -89,8 +96,8 @@ function measureWrappedTextBlock({ ctx: CanvasRenderingContext2D; content: string; canvasHeight: number; - textAlign: CreateTextElement["textAlign"]; - background: CreateTextElement["background"]; + textAlign: TextAlign; + background: TextBackground; fontSize: number; lineHeight: number; }) { @@ -107,7 +114,7 @@ function measureWrappedTextBlock({ textAlign, block, background, - fontSizeRatio: fontSize / DEFAULTS.text.element.fontSize, + fontSizeRatio: fontSize / 15, }); return { @@ -124,13 +131,13 @@ function resolveSubtitleStyle({ fontFamily: string; fontSize: number; color: string; - textAlign: CreateTextElement["textAlign"]; - fontWeight: CreateTextElement["fontWeight"]; - fontStyle: CreateTextElement["fontStyle"]; - textDecoration: CreateTextElement["textDecoration"]; + textAlign: TextAlign; + fontWeight: TextFontWeight; + fontStyle: TextFontStyle; + textDecoration: TextDecoration; letterSpacing: number; lineHeight: number; - background: CreateTextElement["background"]; + background: TextBackground; placement: NonNullable<SubtitleStyleOverrides["placement"]>; } { const fontSize = @@ -139,18 +146,17 @@ function resolveSubtitleStyle({ : (style?.fontSize ?? SUBTITLE_FONT_SIZE); return { - fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily, + fontFamily: style?.fontFamily ?? "Arial", fontSize, - color: style?.color ?? DEFAULTS.text.element.color, + color: style?.color ?? "#ffffff", textAlign: style?.textAlign ?? "center", fontWeight: style?.fontWeight ?? "bold", - fontStyle: style?.fontStyle ?? DEFAULTS.text.element.fontStyle, - textDecoration: - style?.textDecoration ?? DEFAULTS.text.element.textDecoration, + fontStyle: style?.fontStyle ?? "normal", + textDecoration: style?.textDecoration ?? "none", letterSpacing: style?.letterSpacing ?? DEFAULTS.text.letterSpacing, lineHeight: style?.lineHeight ?? DEFAULTS.text.lineHeight, background: { - ...DEFAULTS.text.element.background, + ...DEFAULTS.text.background, enabled: false, ...(style?.background ?? {}), }, @@ -188,7 +194,7 @@ function resolvePositionX({ visualRect, }: { canvasWidth: number; - textAlign: CreateTextElement["textAlign"]; + textAlign: TextAlign; placement: ReturnType<typeof resolveSubtitleStyle>["placement"]; visualRect: { left: number; width: number }; }): number { @@ -309,25 +315,34 @@ export function buildSubtitleTextElement({ return { ...DEFAULTS.text.element, name: `Caption ${index + 1}`, - content, - duration: Math.round(caption.duration * TICKS_PER_SECOND), - startTime: Math.round(caption.startTime * TICKS_PER_SECOND), - fontSize: style.fontSize, - fontFamily: style.fontFamily, - color: style.color, - textAlign: style.textAlign, - fontWeight: style.fontWeight, - fontStyle: style.fontStyle, - textDecoration: style.textDecoration, - letterSpacing: style.letterSpacing, - lineHeight: style.lineHeight, - background: style.background, - transform: { - ...DEFAULTS.text.element.transform, - position: { - x: positionX, - y: positionY, - }, + duration: mediaTimeFromSeconds({ seconds: caption.duration }), + startTime: mediaTimeFromSeconds({ seconds: caption.startTime }), + params: { + ...DEFAULTS.text.element.params, + content, + fontSize: style.fontSize, + fontFamily: style.fontFamily, + color: style.color, + textAlign: style.textAlign, + fontWeight: style.fontWeight, + fontStyle: style.fontStyle, + textDecoration: style.textDecoration, + letterSpacing: style.letterSpacing, + lineHeight: style.lineHeight, + "background.enabled": style.background.enabled, + "background.color": style.background.color, + "background.cornerRadius": + style.background.cornerRadius ?? DEFAULTS.text.background.cornerRadius, + "background.paddingX": + style.background.paddingX ?? DEFAULTS.text.background.paddingX, + "background.paddingY": + style.background.paddingY ?? DEFAULTS.text.background.paddingY, + "background.offsetX": + style.background.offsetX ?? DEFAULTS.text.background.offsetX, + "background.offsetY": + style.background.offsetY ?? DEFAULTS.text.background.offsetY, + "transform.positionX": positionX, + "transform.positionY": positionY, }, }; } diff --git a/apps/web/src/subtitles/components/assets-view.tsx b/apps/web/src/subtitles/components/assets-view.tsx index 7e2ddad9..84480fa4 100644 --- a/apps/web/src/subtitles/components/assets-view.tsx +++ b/apps/web/src/subtitles/components/assets-view.tsx @@ -64,6 +64,7 @@ const IDLE_STATE: ProcessingState = { warnings: [], }; +/* eslint-disable opencut/prefer-object-params -- React reducers must accept (state, action). */ function processingReducer( state: ProcessingState, action: ProcessingAction, @@ -80,6 +81,7 @@ function processingReducer( return { status: "idle", error: action.error, warnings: [] }; } } +/* eslint-enable opencut/prefer-object-params */ export function Captions() { const [selectedLanguage, setSelectedLanguage] = diff --git a/apps/web/src/subtitles/insert.ts b/apps/web/src/subtitles/insert.ts index 50d6c40b..2a71e181 100644 --- a/apps/web/src/subtitles/insert.ts +++ b/apps/web/src/subtitles/insert.ts @@ -18,7 +18,7 @@ export function insertCaptionChunksAsTextTrack({ return null; } - const addTrackCommand = new AddTrackCommand("text", 0); + const addTrackCommand = new AddTrackCommand({ type: "text", index: 0 }); const trackId = addTrackCommand.getTrackId(); const canvasSize = editor.project.getActive().settings.canvasSize; const insertCommands = captions.map( diff --git a/apps/web/src/subtitles/types.ts b/apps/web/src/subtitles/types.ts index e4f5db3c..ec485f22 100644 --- a/apps/web/src/subtitles/types.ts +++ b/apps/web/src/subtitles/types.ts @@ -1,4 +1,10 @@ -import type { TextBackground, TextElement } from "@/timeline"; +import type { TextBackground } from "@/text/background"; +import type { + TextAlign, + TextDecoration, + TextFontStyle, + TextFontWeight, +} from "@/text/primitives"; import type { CaptionChunk } from "@/transcription/types"; export interface SubtitlePlacementStyle { @@ -26,10 +32,10 @@ export interface SubtitleStyleOverrides { color?: string; background?: Pick<TextBackground, "enabled" | "color"> & Partial<Omit<TextBackground, "enabled" | "color">>; - textAlign?: TextElement["textAlign"]; - fontWeight?: TextElement["fontWeight"]; - fontStyle?: TextElement["fontStyle"]; - textDecoration?: TextElement["textDecoration"]; + textAlign?: TextAlign; + fontWeight?: TextFontWeight; + fontStyle?: TextFontStyle; + textDecoration?: TextDecoration; letterSpacing?: number; lineHeight?: number; placement?: SubtitlePlacementStyle; diff --git a/apps/web/src/text/background.ts b/apps/web/src/text/background.ts index be5ab6f5..baa67a52 100644 --- a/apps/web/src/text/background.ts +++ b/apps/web/src/text/background.ts @@ -1,2 +1,12 @@ export const CORNER_RADIUS_MIN = 0; export const CORNER_RADIUS_MAX = 100; + +export interface TextBackground { + enabled: boolean; + color: string; + cornerRadius?: number; + paddingX?: number; + paddingY?: number; + offsetX?: number; + offsetY?: number; +} diff --git a/apps/web/src/text/components/assets-view.tsx b/apps/web/src/text/components/assets-view.tsx index 25a49893..036bc898 100644 --- a/apps/web/src/text/components/assets-view.tsx +++ b/apps/web/src/text/components/assets-view.tsx @@ -1,46 +1,47 @@ -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import { useEditor } from "@/editor/use-editor";
-import { DEFAULTS } from "@/timeline/defaults";
-import { buildTextElement } from "@/timeline/element-utils";
-
-export function TextView() {
- const editor = useEditor();
-
- const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
- const activeScene = editor.scenes.getActiveScene();
- if (!activeScene) return;
-
- const element = buildTextElement({
- raw: DEFAULTS.text.element,
- startTime: currentTime,
- });
-
- editor.timeline.insertElement({
- element,
- placement: { mode: "auto" },
- });
- };
-
- return (
- <PanelView title="Text">
- <DraggableItem
- name="Default text"
- preview={
- <div className="bg-accent flex size-full items-center justify-center rounded">
- <span className="text-xs select-none">Default text</span>
- </div>
- }
- dragData={{
- id: "temp-text-id",
- type: DEFAULTS.text.element.type,
- name: DEFAULTS.text.element.name,
- content: DEFAULTS.text.element.content,
- }}
- aspectRatio={1}
- onAddToTimeline={handleAddToTimeline}
- shouldShowLabel={false}
- />
- </PanelView>
- );
-}
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; +import { useEditor } from "@/editor/use-editor"; +import { DEFAULTS } from "@/timeline/defaults"; +import { buildTextElement } from "@/timeline/element-utils"; +import type { MediaTime } from "@/wasm"; + +export function TextView() { + const editor = useEditor(); + + const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => { + const activeScene = editor.scenes.getActiveScene(); + if (!activeScene) return; + + const element = buildTextElement({ + raw: DEFAULTS.text.element, + startTime: currentTime, + }); + + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + }; + + return ( + <PanelView title="Text"> + <DraggableItem + name="Default text" + preview={ + <div className="bg-accent flex size-full items-center justify-center rounded"> + <span className="text-xs select-none">Default text</span> + </div> + } + dragData={{ + id: "temp-text-id", + type: DEFAULTS.text.element.type, + name: DEFAULTS.text.element.name, + content: "Default text", + }} + aspectRatio={1} + onAddToTimeline={handleAddToTimeline} + shouldShowLabel={false} + /> + </PanelView> + ); +} diff --git a/apps/web/src/text/components/text-tab.tsx b/apps/web/src/text/components/text-tab.tsx deleted file mode 100644 index 0f0b274a..00000000 --- a/apps/web/src/text/components/text-tab.tsx +++ /dev/null @@ -1,767 +0,0 @@ -"use client"; - -import { Textarea } from "@/components/ui/textarea"; -import { FontPicker } from "@/components/ui/font-picker"; -import type { TextElement } from "@/timeline"; -import { NumberField } from "@/components/ui/number-field"; -import { useRef } from "react"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { Button } from "@/components/ui/button"; -import { uppercase } from "@/utils/string"; -import { clamp, formatNumberForDisplay } from "@/utils/math"; -import { useEditor } from "@/editor/use-editor"; -import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "@/text/background"; -import { - DEFAULT_TEXT_COLOR, - MAX_FONT_SIZE, - MIN_FONT_SIZE, -} from "@/text/typography"; -import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; -import { useKeyframedColorProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-color-property"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { isPropertyAtDefault } from "@/rendering/components/transform-tab"; -import { - resolveColorAtTime, - resolveNumberAtTime, -} from "@/animation/values"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - MinusSignIcon, - PlusSignIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; -import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons"; -import { DEFAULTS } from "@/timeline/defaults"; -import { cn } from "@/utils/ui"; - -export function TextTab({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - return ( - <div className="flex flex-col"> - <ContentSection element={element} trackId={trackId} /> - <TypographySection element={element} trackId={trackId} /> - <SpacingSection element={element} trackId={trackId} /> - <BackgroundSection element={element} trackId={trackId} /> - </div> - ); -} - -function ContentSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const content = usePropertyDraft({ - displayValue: element.content, - parse: (input) => input, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { content: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:content`}> - <SectionHeader> - <SectionTitle>Content</SectionTitle> - </SectionHeader> - <SectionContent> - <Textarea - placeholder="Name" - value={content.displayValue} - className="min-h-20" - onFocus={content.onFocus} - onChange={content.onChange} - onBlur={content.onBlur} - /> - </SectionContent> - </Section> - ); -} - -function TypographySection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTextColor = resolveColorAtTime({ - baseColor: element.color, - animations: element.animations, - propertyPath: "color", - localTime, - }); - - const textColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedTextColor, - buildBaseUpdates: ({ value }) => ({ color: value }), - }); - - const fontSize = usePropertyDraft({ - displayValue: element.fontSize.toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: MIN_FONT_SIZE, - max: MAX_FONT_SIZE, - }); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { fontSize: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:typography`}> - <SectionHeader> - <SectionTitle>Typography</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField label="Font"> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { fontFamily: value }, - }, - ], - }) - } - /> - </SectionField> - <SectionField label="Size"> - <NumberField - value={fontSize.displayValue} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={fontSize.onFocus} - onChange={fontSize.onChange} - onBlur={fontSize.onBlur} - onScrub={fontSize.scrubTo} - onScrubEnd={fontSize.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - fontSize: DEFAULTS.text.element.fontSize, - }, - }, - ], - }) - } - isDefault={element.fontSize === DEFAULTS.text.element.fontSize} - icon={<HugeiconsIcon icon={TextFontIcon} />} - /> - </SectionField> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={textColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle text color keyframe" - onToggle={textColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={uppercase({ - string: resolvedTextColor.replace("#", ""), - })} - onChange={(color) => textColor.onChange({ color: `#${color}` })} - onChangeEnd={textColor.onChangeEnd} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} - -function SpacingSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const letterSpacing = usePropertyDraft({ - displayValue: Math.round( - element.letterSpacing ?? DEFAULTS.text.letterSpacing, - ).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { letterSpacing: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const lineHeight = usePropertyDraft({ - displayValue: formatNumberForDisplay({ - value: element.lineHeight ?? DEFAULTS.text.lineHeight, - fractionDigits: 1, - }), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) - ? null - : Math.max(0.1, Math.round(parsed * 10) / 10); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { lineHeight: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:spacing`}> - <SectionHeader> - <SectionTitle>Spacing</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField label="Letter spacing" className="w-1/2"> - <NumberField - value={letterSpacing.displayValue} - onFocus={letterSpacing.onFocus} - onChange={letterSpacing.onChange} - onBlur={letterSpacing.onBlur} - onScrub={letterSpacing.scrubTo} - onScrubEnd={letterSpacing.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { letterSpacing: DEFAULTS.text.letterSpacing }, - }, - ], - }) - } - isDefault={ - (element.letterSpacing ?? DEFAULTS.text.letterSpacing) === - DEFAULTS.text.letterSpacing - } - icon={<OcTextWidthIcon size={14} />} - /> - </SectionField> - <SectionField label="Line height" className="w-1/2"> - <NumberField - value={lineHeight.displayValue} - onFocus={lineHeight.onFocus} - onChange={lineHeight.onChange} - onBlur={lineHeight.onBlur} - onScrub={lineHeight.scrubTo} - onScrubEnd={lineHeight.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { lineHeight: DEFAULTS.text.lineHeight }, - }, - ], - }) - } - isDefault={ - (element.lineHeight ?? DEFAULTS.text.lineHeight) === - DEFAULTS.text.lineHeight - } - icon={<OcTextHeightIcon size={14} />} - /> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} - -function BackgroundSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedBgColor = resolveColorAtTime({ - baseColor: element.background.color, - animations: element.animations, - propertyPath: "background.color", - localTime, - }); - - const bgColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedBgColor, - buildBaseUpdates: ({ value }) => ({ - background: { ...element.background, color: value }, - }), - }); - - const bg = element.background; - - const resolvedPaddingX = resolveNumberAtTime({ - baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - }); - const resolvedPaddingY = resolveNumberAtTime({ - baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - }); - const resolvedOffsetX = resolveNumberAtTime({ - baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - }); - const resolvedOffsetY = resolveNumberAtTime({ - baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - }); - const resolvedCornerRadius = resolveNumberAtTime({ - baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - }); - - const paddingX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingX: value }, - }), - }); - - const paddingY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingY: value }, - }), - }); - - const offsetX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetX: value }, - }), - }); - - const offsetY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetY: value }, - }), - }); - - const cornerRadius = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedCornerRadius).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - }); - }, - valueAtPlayhead: resolvedCornerRadius, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, cornerRadius: value }, - }), - }); - - const toggleBackgroundEnabled = () => { - const enabled = !element.background.enabled; - const color = - enabled && element.background.color === "transparent" - ? lastSelectedColor.current - : element.background.color; - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - background: { - ...element.background, - enabled, - color, - }, - }, - }, - ], - }); - }; - - return ( - <Section - collapsible - defaultOpen={element.background.enabled} - sectionKey={`${element.id}:background`} - > - <SectionHeader - trailing={ - <Button - variant="ghost" - size="icon" - onClick={(event) => { - event.stopPropagation(); - toggleBackgroundEnabled(); - }} - > - <HugeiconsIcon - icon={element.background.enabled ? MinusSignIcon : PlusSignIcon} - strokeWidth={1} - /> - </Button> - } - > - <SectionTitle>Background</SectionTitle> - </SectionHeader> - <SectionContent - className={cn( - !element.background.enabled && "pointer-events-none opacity-50", - )} - > - <SectionFields> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={bgColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background color keyframe" - onToggle={bgColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={ - !element.background.enabled || - element.background.color === "transparent" - ? lastSelectedColor.current.replace("#", "") - : resolvedBgColor.replace("#", "") - } - onChange={(color) => { - const hexColor = `#${color}`; - if (color !== "transparent") { - lastSelectedColor.current = hexColor; - } - bgColor.onChange({ color: hexColor }); - }} - onChangeEnd={bgColor.onChangeEnd} - /> - </SectionField> - <div className="flex items-start gap-2"> - <SectionField - label="Width" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background width keyframe" - onToggle={paddingX.toggleKeyframe} - /> - } - > - <NumberField - icon="W" - value={paddingX.displayValue} - min={0} - onFocus={paddingX.onFocus} - onChange={paddingX.onChange} - onBlur={paddingX.onBlur} - onScrub={paddingX.scrubTo} - onScrubEnd={paddingX.commitScrub} - onReset={() => - paddingX.commitValue({ - value: DEFAULTS.text.background.paddingX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingX, - staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - defaultValue: DEFAULTS.text.background.paddingX, - })} - /> - </SectionField> - <SectionField - label="Height" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background height keyframe" - onToggle={paddingY.toggleKeyframe} - /> - } - > - <NumberField - icon="H" - value={paddingY.displayValue} - min={0} - onFocus={paddingY.onFocus} - onChange={paddingY.onChange} - onBlur={paddingY.onBlur} - onScrub={paddingY.scrubTo} - onScrubEnd={paddingY.commitScrub} - onReset={() => - paddingY.commitValue({ - value: DEFAULTS.text.background.paddingY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingY, - staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - defaultValue: DEFAULTS.text.background.paddingY, - })} - /> - </SectionField> - </div> - <div className="flex items-start gap-2"> - <SectionField - label="X-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle x-offset keyframe" - onToggle={offsetX.toggleKeyframe} - /> - } - > - <NumberField - icon="X" - value={offsetX.displayValue} - onFocus={offsetX.onFocus} - onChange={offsetX.onChange} - onBlur={offsetX.onBlur} - onScrub={offsetX.scrubTo} - onScrubEnd={offsetX.commitScrub} - onReset={() => - offsetX.commitValue({ - value: DEFAULTS.text.background.offsetX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetX, - staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - defaultValue: DEFAULTS.text.background.offsetX, - })} - /> - </SectionField> - <SectionField - label="Y-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle y-offset keyframe" - onToggle={offsetY.toggleKeyframe} - /> - } - > - <NumberField - icon="Y" - value={offsetY.displayValue} - onFocus={offsetY.onFocus} - onChange={offsetY.onChange} - onBlur={offsetY.onBlur} - onScrub={offsetY.scrubTo} - onScrubEnd={offsetY.commitScrub} - onReset={() => - offsetY.commitValue({ - value: DEFAULTS.text.background.offsetY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetY, - staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - defaultValue: DEFAULTS.text.background.offsetY, - })} - /> - </SectionField> - </div> - <SectionField - label="Corner radius" - beforeLabel={ - <KeyframeToggle - isActive={cornerRadius.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle corner radius keyframe" - onToggle={cornerRadius.toggleKeyframe} - /> - } - > - <NumberField - icon="R" - value={cornerRadius.displayValue} - min={CORNER_RADIUS_MIN} - max={CORNER_RADIUS_MAX} - onFocus={cornerRadius.onFocus} - onChange={cornerRadius.onChange} - onBlur={cornerRadius.onBlur} - onScrub={cornerRadius.scrubTo} - onScrubEnd={cornerRadius.commitScrub} - onReset={() => - cornerRadius.commitValue({ value: CORNER_RADIUS_MIN }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedCornerRadius, - staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - defaultValue: CORNER_RADIUS_MIN, - })} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/text/layout.ts b/apps/web/src/text/layout.ts index aabf5ca9..c52abc30 100644 --- a/apps/web/src/text/layout.ts +++ b/apps/web/src/text/layout.ts @@ -1,5 +1,6 @@ -import type { TextBackground, TextElement } from "@/timeline"; +import type { TextBackground } from "@/text/background"; import { DEFAULTS } from "@/timeline/defaults"; +import type { TextAlign } from "@/text/primitives"; type TextRect = { left: number; @@ -78,7 +79,7 @@ function getTextRect({ textAlign, block, }: { - textAlign: TextElement["textAlign"]; + textAlign: TextAlign; block: TextBlockMeasurement; }): TextRect { const textAlignToLeft: Record<typeof textAlign, number> = { @@ -114,7 +115,7 @@ export function getTextBackgroundRect({ background, fontSizeRatio = 1, }: { - textAlign: TextElement["textAlign"]; + textAlign: TextAlign; block: TextBlockMeasurement; background: TextBackground; fontSizeRatio?: number; @@ -145,7 +146,7 @@ export function getTextVisualRect({ background, fontSizeRatio = 1, }: { - textAlign: TextElement["textAlign"]; + textAlign: TextAlign; block: TextBlockMeasurement; background: TextBackground; fontSizeRatio?: number; diff --git a/apps/web/src/text/measure-element.ts b/apps/web/src/text/measure-element.ts index ec14421b..28e97eac 100644 --- a/apps/web/src/text/measure-element.ts +++ b/apps/web/src/text/measure-element.ts @@ -1,6 +1,7 @@ import { CORNER_RADIUS_MIN } from "@/text/background"; import { DEFAULTS } from "@/timeline/defaults"; -import type { TextBackground, TextElement } from "@/timeline"; +import type { TextElement } from "@/timeline"; +import type { TextBackground } from "@/text/background"; import { resolveNumberAtTime } from "@/animation/values"; import { getTextVisualRect, @@ -8,6 +9,11 @@ import { import { measureTextLayout, type MeasuredTextLayout, + type TextAlign, + type TextDecoration, + type TextFontStyle, + type TextFontWeight, + type TextLayoutParams, } from "./primitives"; export interface ResolvedTextBackground extends TextBackground { @@ -67,23 +73,14 @@ export function measureTextElement({ localTime: number; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; }): MeasuredTextElement { + const text = buildTextLayoutParamsFromElement({ element }); const measuredLayout = measureTextLayout({ - text: { - content: element.content, - fontSize: element.fontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textAlign: element.textAlign, - textDecoration: element.textDecoration, - letterSpacing: element.letterSpacing, - lineHeight: element.lineHeight, - }, + text, canvasHeight, ctx, }); - const bg = element.background; + const bg = buildTextBackgroundFromElement({ element }); const resolvedBackground: ResolvedTextBackground = { ...bg, paddingX: resolveNumberAtTime({ @@ -119,7 +116,7 @@ export function measureTextElement({ }; const visualRect = getTextVisualRect({ - textAlign: element.textAlign, + textAlign: text.textAlign, block: measuredLayout.block, background: resolvedBackground, fontSizeRatio: measuredLayout.fontSizeRatio, @@ -131,3 +128,180 @@ export function measureTextElement({ visualRect, }; } + +export function buildTextLayoutParamsFromElement({ + element, +}: { + element: TextElement; +}): TextLayoutParams { + return { + content: readStringParam({ + params: element.params, + key: "content", + fallback: "Default text", + }), + fontSize: readNumberParam({ + params: element.params, + key: "fontSize", + fallback: 15, + }), + fontFamily: readStringParam({ + params: element.params, + key: "fontFamily", + fallback: "Arial", + }), + fontWeight: readFontWeight({ + value: element.params.fontWeight, + fallback: "normal", + }), + fontStyle: readFontStyle({ + value: element.params.fontStyle, + fallback: "normal", + }), + textAlign: readTextAlign({ + value: element.params.textAlign, + fallback: "center", + }), + textDecoration: readTextDecoration({ + value: element.params.textDecoration, + fallback: "none", + }), + letterSpacing: readNumberParam({ + params: element.params, + key: "letterSpacing", + fallback: DEFAULTS.text.letterSpacing, + }), + lineHeight: readNumberParam({ + params: element.params, + key: "lineHeight", + fallback: DEFAULTS.text.lineHeight, + }), + }; +} + +export function buildTextBackgroundFromElement({ + element, +}: { + element: TextElement; +}): TextBackground { + return { + enabled: readBooleanParam({ + params: element.params, + key: "background.enabled", + fallback: DEFAULTS.text.background.enabled, + }), + color: readStringParam({ + params: element.params, + key: "background.color", + fallback: DEFAULTS.text.background.color, + }), + cornerRadius: readNumberParam({ + params: element.params, + key: "background.cornerRadius", + fallback: DEFAULTS.text.background.cornerRadius, + }), + paddingX: readNumberParam({ + params: element.params, + key: "background.paddingX", + fallback: DEFAULTS.text.background.paddingX, + }), + paddingY: readNumberParam({ + params: element.params, + key: "background.paddingY", + fallback: DEFAULTS.text.background.paddingY, + }), + offsetX: readNumberParam({ + params: element.params, + key: "background.offsetX", + fallback: DEFAULTS.text.background.offsetX, + }), + offsetY: readNumberParam({ + params: element.params, + key: "background.offsetY", + fallback: DEFAULTS.text.background.offsetY, + }), + }; +} + +function readStringParam({ + params, + key, + fallback, +}: { + params: TextElement["params"]; + key: string; + fallback: string; +}): string { + const value = params[key]; + return typeof value === "string" ? value : fallback; +} + +function readNumberParam({ + params, + key, + fallback, +}: { + params: TextElement["params"]; + key: string; + fallback: number; +}): number { + const value = params[key]; + return typeof value === "number" ? value : fallback; +} + +function readBooleanParam({ + params, + key, + fallback, +}: { + params: TextElement["params"]; + key: string; + fallback: boolean; +}): boolean { + const value = params[key]; + return typeof value === "boolean" ? value : fallback; +} + +function readTextAlign({ + value, + fallback, +}: { + value: unknown; + fallback: TextAlign; +}): TextAlign { + return value === "left" || value === "center" || value === "right" + ? value + : fallback; +} + +function readFontWeight({ + value, + fallback, +}: { + value: unknown; + fallback: TextFontWeight; +}): TextFontWeight { + return value === "bold" || value === "normal" ? value : fallback; +} + +function readFontStyle({ + value, + fallback, +}: { + value: unknown; + fallback: TextFontStyle; +}): TextFontStyle { + return value === "italic" || value === "normal" ? value : fallback; +} + +function readTextDecoration({ + value, + fallback, +}: { + value: unknown; + fallback: TextDecoration; +}): TextDecoration { + return value === "none" || value === "underline" || value === "line-through" + ? value + : fallback; +} diff --git a/apps/web/src/text/primitives.ts b/apps/web/src/text/primitives.ts index 9a9262a4..b2eefa95 100644 --- a/apps/web/src/text/primitives.ts +++ b/apps/web/src/text/primitives.ts @@ -85,7 +85,7 @@ export function resolveTextLayout({ const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing; const lineHeightPx = scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight); - const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize; + const fontSizeRatio = text.fontSize / 15; return { scaledFontSize, diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts new file mode 100644 index 00000000..40a0e645 --- /dev/null +++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import type { Transform } from "@/rendering"; +import type { SceneTracks, VideoElement } from "@/timeline"; +import { applyElementUpdate } from "@/timeline/update-pipeline"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; + +function buildTransform(): Transform { + return { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; +} + +function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement { + return { + id: "video-1", + type: "video", + name: "Video 1", + startTime: ZERO_MEDIA_TIME, + duration: mediaTime({ ticks: 10 }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + mediaId: "media-1", + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, + ...overrides, + }; +} + +function buildTracks(element: VideoElement): SceneTracks { + return { + overlay: [], + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [element], + }, + audio: [], + }; +} + +describe("applyElementUpdate", () => { + test("rounds retimed durations back to integer media time", () => { + const element = buildVideoElement(); + const tracks = buildTracks(element); + + const updatedElement = applyElementUpdate({ + element, + patch: { + retime: { rate: 1.5 }, + }, + context: { + tracks, + trackId: tracks.main.id, + }, + }); + + expect(updatedElement.duration).toBe(7); + expect(Number.isInteger(updatedElement.duration)).toBe(true); + }); +}); diff --git a/apps/web/src/timeline/animation-properties.ts b/apps/web/src/timeline/animation-properties.ts deleted file mode 100644 index 31479a58..00000000 --- a/apps/web/src/timeline/animation-properties.ts +++ /dev/null @@ -1,425 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, - ElementAnimations, - NumericSpec, -} from "@/animation/types"; -import { upsertPathKeyframe } from "@/animation"; -import { parseColorToLinearRgba } from "@/animation/binding-values"; -import { isAnimationPropertyPath } from "@/animation/path"; -import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; -import { snapToStep } from "@/utils/math"; -import type { TimelineElement } from "@/timeline"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/text/background"; -import { - canElementHaveAudio, - isVisualElement, -} from "@/timeline/element-utils"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { DEFAULTS } from "@/timeline/defaults"; - -export interface AnimationPropertyDefinition { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial<Record<string, NumericSpec>>; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceNumberValue({ - value, - numericRange, -}: { - value: AnimationValue; - numericRange?: NumericSpec; -}): number | null { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ value, numericRange }); -} - -function coerceColorValue({ - value, -}: { - value: AnimationValue; -}): string | null { - return typeof value === "string" && parseColorToLinearRgba({ color: value }) - ? value - : null; -} - -function createNumberPropertyDefinition({ - numericRange, - supportsElement, - getValue, - setValue, -}: { - numericRange?: NumericSpec; - supportsElement: AnimationPropertyDefinition["supportsElement"]; - getValue: AnimationPropertyDefinition["getValue"]; - setValue: AnimationPropertyDefinition["setValue"]; -}): AnimationPropertyDefinition { - return { - kind: "number", - defaultInterpolation: "linear", - numericRanges: numericRange ? { value: numericRange } : undefined, - supportsElement, - getValue, - coerceValue: ({ value }) => - coerceNumberValue({ - value, - numericRange, - }), - setValue, - }; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.positionX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.x : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, x: value as number }, - }, - } - : element, - }), - "transform.positionY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.y : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, y: value as number }, - }, - } - : element, - }), - "transform.scaleX": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }), - "transform.scaleY": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }), - "transform.rotate": createNumberPropertyDefinition({ - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }), - opacity: createNumberPropertyDefinition({ - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }), - volume: createNumberPropertyDefinition({ - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }), - color: { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }), - "background.paddingY": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }), - "background.offsetX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }), - "background.offsetY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }), - "background.cornerRadius": createNumberPropertyDefinition({ - numericRange: { - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - step: 1, - }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }), -}; - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const definition = getAnimationPropertyDefinition({ propertyPath }); - const coercedValue = definition.coerceValue({ value }); - if (coercedValue === null || !definition.supportsElement({ element })) { - return element; - } - - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.coerceValue({ value }); -} - -export function upsertElementKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath, - value, - }); - if (coercedValue === null) { - return animations; - } - - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return upsertPathKeyframe({ - animations, - propertyPath, - time, - value: coercedValue, - interpolation, - keyframeId, - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - coerceValue: ({ value: nextValue }) => - coerceAnimationValueForProperty({ - propertyPath, - value: nextValue, - }), - }); -} diff --git a/apps/web/src/timeline/animation-snap-points.ts b/apps/web/src/timeline/animation-snap-points.ts index f7bf549a..e64de11f 100644 --- a/apps/web/src/timeline/animation-snap-points.ts +++ b/apps/web/src/timeline/animation-snap-points.ts @@ -1,6 +1,7 @@ import { getElementKeyframes } from "@/animation"; import type { SceneTracks } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import { addMediaTime } from "@/wasm"; export function getAnimationKeyframeSnapPointsForTimeline({ tracks, @@ -22,7 +23,7 @@ export function getAnimationKeyframeSnapPointsForTimeline({ animations: element.animations, })) { snapPoints.push({ - time: element.startTime + keyframe.time, + time: addMediaTime({ a: element.startTime, b: keyframe.time }), type: "keyframe", elementId: element.id, trackId: track.id, diff --git a/apps/web/src/timeline/animation-targets.ts b/apps/web/src/timeline/animation-targets.ts index f9d53fcc..4ee764b6 100644 --- a/apps/web/src/timeline/animation-targets.ts +++ b/apps/web/src/timeline/animation-targets.ts @@ -1,17 +1,9 @@ import type { - AnimationBindingKind, AnimationInterpolation, AnimationPath, - AnimationValue, NumericSpec, } from "@/animation/types"; import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamNumericRange, - getAnimationParamValueKind, -} from "@/animation/animated-params"; -import { parseEffectParamPath, } from "@/animation/effect-param-channel"; import { @@ -19,38 +11,97 @@ import { } from "@/animation/graphic-param-channel"; import { effectsRegistry, registerDefaultEffects } from "@/effects"; import { getGraphicDefinition } from "@/graphics"; -import type { ParamDefinition } from "@/params"; +import { + coerceParamValue, + getParamDefaultInterpolation, + getParamChannelLayout, + getParamNumericRange, + type ParamChannelLayout, + type ParamDefinition, + type ParamValue, + type ParamValues, +} from "@/params"; +import { + getElementParam, +} from "@/params/registry"; import type { TimelineElement } from "@/timeline"; import { isVisualElement } from "@/timeline/element-utils"; -import { isAnimationPropertyPath } from "@/animation/path"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getElementBaseValueForProperty, - withElementBaseValueForProperty, -} from "./animation-properties"; export interface AnimationPathDescriptor { - kind: AnimationBindingKind; + channelLayout: ParamChannelLayout; defaultInterpolation: AnimationInterpolation; numericRanges?: Partial<Record<string, NumericSpec>>; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - getBaseValue: () => AnimationValue | null; - setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; + coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null; + getBaseValue: () => ParamValue | null; + setBaseValue: ({ value }: { value: ParamValue }) => TimelineElement; } -// Number/discrete bindings expose a single component named "value" -// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry -// numeric ranges yet — revisit when one does. +// Leaf params expose a single component named "value". Composite params don't +// carry numeric ranges yet — revisit when one does. function paramNumericRanges({ param, }: { param: ParamDefinition; }): Partial<Record<string, NumericSpec>> | undefined { - const range = getAnimationParamNumericRange({ param }); + const range = getParamNumericRange({ param }); return range ? { value: range } : undefined; } +function buildParamDescriptor({ + param, + baseParams, + setParams, +}: { + param: ParamDefinition; + baseParams: ParamValues; + setParams: (params: ParamValues) => TimelineElement; +}): AnimationPathDescriptor | null { + if (param.keyframable === false) { + return null; + } + + return { + channelLayout: getParamChannelLayout({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: paramNumericRanges({ param }), + coerceValue: ({ value }) => coerceParamValue({ param, value }), + getBaseValue: () => baseParams[param.key] ?? param.default, + setBaseValue: ({ value }) => { + const coercedValue = coerceParamValue({ param, value }); + if (coercedValue === null) { + return setParams(baseParams); + } + + return setParams({ + ...baseParams, + [param.key]: coercedValue, + }); + }, + }; +} + +function buildElementParamDescriptor({ + element, + paramKey, +}: { + element: TimelineElement; + paramKey: string; +}): AnimationPathDescriptor | null { + const param = getElementParam({ element, key: paramKey }); + if (!param) { + return null; + } + + return buildParamDescriptor({ + param, + baseParams: element.params, + setParams: (params) => ({ + ...element, + params, + }), + }); +} + function buildGraphicParamDescriptor({ element, paramKey, @@ -70,27 +121,14 @@ function buildGraphicParamDescriptor({ return null; } - return { - kind: getAnimationParamValueKind({ param }), - defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), - numericRanges: paramNumericRanges({ param }), - coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), - getBaseValue: () => element.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - params: { - ...element.params, - [param.key]: coercedValue, - }, - }; - }, - }; + return buildParamDescriptor({ + param, + baseParams: element.params, + setParams: (params) => ({ + ...element, + params, + }), + }); } function buildEffectParamDescriptor({ @@ -118,35 +156,22 @@ function buildEffectParamDescriptor({ return null; } - return { - kind: getAnimationParamValueKind({ param }), - defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), - numericRanges: paramNumericRanges({ param }), - coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), - getBaseValue: () => effect.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - effects: - element.effects?.map((candidate) => - candidate.id !== effectId - ? candidate - : { - ...candidate, - params: { - ...candidate.params, - [param.key]: coercedValue, - }, - }, - ) ?? element.effects, - }; - }, - }; + return buildParamDescriptor({ + param, + baseParams: effect.params, + setParams: (params) => ({ + ...element, + effects: + element.effects?.map((candidate) => + candidate.id !== effectId + ? candidate + : { + ...candidate, + params, + }, + ) ?? element.effects, + }), + }); } export function resolveAnimationTarget({ @@ -156,41 +181,12 @@ export function resolveAnimationTarget({ element: TimelineElement; path: AnimationPath; }): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(path)) { - const propertyDefinition = getAnimationPropertyDefinition({ - propertyPath: path, - }); - if (!propertyDefinition.supportsElement({ element })) { - return null; - } - - return { - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - numericRanges: propertyDefinition.numericRanges, - coerceValue: ({ value }) => - coerceAnimationValueForProperty({ - propertyPath: path, - value, - }), - getBaseValue: () => - getElementBaseValueForProperty({ - element, - propertyPath: path, - }), - setBaseValue: ({ value }) => { - const coercedValue = propertyDefinition.coerceValue({ value }); - if (coercedValue === null) { - return element; - } - - return withElementBaseValueForProperty({ - element, - propertyPath: path, - value: coercedValue, - }); - }, - }; + const elementParamTarget = buildElementParamDescriptor({ + element, + paramKey: path, + }); + if (elementParamTarget) { + return elementParamTarget; } const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); diff --git a/apps/web/src/timeline/audio-separation/index.ts b/apps/web/src/timeline/audio-separation/index.ts index beb488de..6917378c 100644 --- a/apps/web/src/timeline/audio-separation/index.ts +++ b/apps/web/src/timeline/audio-separation/index.ts @@ -87,8 +87,13 @@ export function buildSeparatedAudioElement({ trimStart: sourceElement.trimStart, trimEnd: sourceElement.trimEnd, sourceDuration: sourceElement.sourceDuration, - volume: sourceElement.volume ?? DEFAULTS.element.volume, - muted: sourceElement.muted ?? false, + params: { + volume: + typeof sourceElement.params.volume === "number" + ? sourceElement.params.volume + : DEFAULTS.element.volume, + muted: sourceElement.params.muted === true, + }, retime: sourceElement.retime ? { rate: sourceElement.retime.rate, @@ -116,28 +121,13 @@ function cloneVolumeAnimations({ }: { animations: ElementAnimations | undefined; }): ElementAnimations | undefined { - const volumeBinding = animations?.bindings.volume; - if (!volumeBinding) { - return undefined; - } - - const subsetChannels = Object.fromEntries( - volumeBinding.components.flatMap((component) => { - const channel = animations?.channels[component.channelId]; - return channel ? [[component.channelId, channel] as const] : []; - }), - ); - if (Object.keys(subsetChannels).length === 0) { + const volumeData = animations?.volume; + if (!volumeData) { return undefined; } return cloneAnimations({ - animations: { - bindings: { - volume: volumeBinding, - }, - channels: subsetChannels, - }, + animations: { volume: volumeData }, shouldRegenerateKeyframeIds: true, }); } diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts index 44df5aaf..6c8f3044 100644 --- a/apps/web/src/timeline/audio-state.ts +++ b/apps/web/src/timeline/audio-state.ts @@ -21,6 +21,23 @@ export function dBToLinear(db: number): number { return 10 ** (clampDb(db) / 20); } +export function getElementVolume({ + element, +}: { + element: AudioCapableElement; +}): number { + const value = element.params.volume; + return typeof value === "number" ? value : 0; +} + +export function isElementMuted({ + element, +}: { + element: AudioCapableElement; +}): boolean { + return element.params.muted === true; +} + export function hasAnimatedVolume({ element, }: { @@ -43,12 +60,12 @@ export function resolveEffectiveAudioGain({ trackMuted?: boolean; localTime: number; }): number { - if (trackMuted || element.muted === true) { + if (trackMuted || isElementMuted({ element })) { return 0; } const resolvedDb = resolveNumberAtTime({ - baseValue: element.volume ?? 0, + baseValue: getElementVolume({ element }), animations: element.animations, propertyPath: "volume", localTime: Math.round(localTime * TICKS_PER_SECOND), diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx index 20e22c57..43ba4ffc 100644 --- a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx +++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx @@ -7,7 +7,6 @@ import type { BookmarkDragState } from "../hooks/use-bookmark-drag"; import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme"; import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout"; import { DEFAULT_FPS } from "@/fps/defaults"; -import { snappedSeekTime } from "opencut-wasm"; import { ArrowTurnBackwardIcon, Delete02Icon, @@ -26,6 +25,14 @@ import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp, formatNumberForDisplay } from "@/utils/math"; import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline"; +import { + type MediaTime, + mediaTimeFromSeconds, + mediaTimeToSeconds, + snapSeekMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; @@ -39,12 +46,12 @@ function seekToBookmarkTime({ time, }: { editor: EditorCore; - time: number; + time: MediaTime; }) { const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); const rate = activeProject?.settings.fps ?? DEFAULT_FPS; - const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; + const snappedTime = snapSeekMediaTime({ time, duration, fps: rate }); editor.playback.seek({ time: snappedTime }); } @@ -137,7 +144,7 @@ function TimelineBookmark({ const displayTime = isDragging ? dragState.currentTime : bookmark.time; const time = bookmark.time; - const bookmarkDuration = bookmark.duration ?? 0; + const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME; const durationWidth = bookmarkDuration > 0 ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) @@ -182,7 +189,7 @@ function TimelineBookmark({ left: `${bookmarkLeft}px`, width: `${bookmarkWidth}px`, }} - aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`} + aria-label={`Bookmark at ${formatNumberForDisplay({ value: mediaTimeToSeconds({ time }), fractionDigits: 1 })}s`} type="button" onMouseDown={handleMouseDown} onClick={handleClick} @@ -286,8 +293,8 @@ function BookmarkPopoverContent({ onPopoverClose, }: { bookmark: Bookmark; - time: number; - timelineDuration: number; + time: MediaTime; + timelineDuration: MediaTime; onPopoverClose: () => void; }) { const editor = useEditor(); @@ -314,9 +321,8 @@ function BookmarkPopoverContent({ note, color, duration, - }: Partial<{ note: string; color: string; duration: number }>) => { - const updates: Partial<{ note: string; color: string; duration: number }> = - {}; + }: Partial<Omit<Bookmark, "time">>) => { + const updates: Partial<Omit<Bookmark, "time">> = {}; if (note !== undefined && note !== bookmark.note) updates.note = note; if ( color !== undefined && @@ -330,6 +336,15 @@ function BookmarkPopoverContent({ if (Object.keys(updates).length === 0) return; editor.scenes.updateBookmark({ time, updates }); }; + const maxDuration = mediaTimeToSeconds({ + time: + timelineDuration > time + ? subMediaTime({ a: timelineDuration, b: time }) + : ZERO_MEDIA_TIME, + }); + const durationSeconds = mediaTimeToSeconds({ + time: bookmark.duration ?? ZERO_MEDIA_TIME, + }); return ( <> @@ -387,7 +402,7 @@ function BookmarkPopoverContent({ type="number" min={0} step={0.1} - value={bookmark.duration ?? 0} + value={durationSeconds} onChange={(event) => { const parsed = parseFloat(event.target.value); const value = Number.isNaN(parsed) @@ -395,9 +410,11 @@ function BookmarkPopoverContent({ : clamp({ value: parsed, min: 0, - max: Math.max(0, timelineDuration - time), + max: maxDuration, }); - handleUpdate({ duration: value }); + handleUpdate({ + duration: mediaTimeFromSeconds({ seconds: value }), + }); }} className="h-8 text-sm" containerClassName="w-full" diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts index 35ee9cca..4c03ef8c 100644 --- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -8,7 +8,6 @@ import { import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame } from "opencut-wasm"; import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; import { buildTimelineSnapPoints, @@ -21,15 +20,16 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import type { Bookmark } from "@/timeline"; +import { roundFrameTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export interface BookmarkDragState { isDragging: boolean; - bookmarkTime: number | null; - currentTime: number; + bookmarkTime: MediaTime | null; + currentTime: MediaTime; } interface PendingBookmarkDrag { - bookmarkTime: number; + bookmarkTime: MediaTime; startMouseX: number; startMouseY: number; } @@ -58,7 +58,7 @@ export function useBookmarkDrag({ const [dragState, setDragState] = useState<BookmarkDragState>({ isDragging: false, bookmarkTime: null, - currentTime: 0, + currentTime: ZERO_MEDIA_TIME, }); const [isPendingDrag, setIsPendingDrag] = useState(false); const pendingDragRef = useRef<PendingBookmarkDrag | null>(null); @@ -69,8 +69,8 @@ export function useBookmarkDrag({ bookmarkTime, initialCurrentTime, }: { - bookmarkTime: number; - initialCurrentTime: number; + bookmarkTime: MediaTime; + initialCurrentTime: MediaTime; }) => { setDragState({ isDragging: true, @@ -85,7 +85,7 @@ export function useBookmarkDrag({ setDragState({ isDragging: false, bookmarkTime: null, - currentTime: 0, + currentTime: ZERO_MEDIA_TIME, }); }, []); @@ -94,9 +94,9 @@ export function useBookmarkDrag({ rawTime, excludeBookmarkTime, }: { - rawTime: number; - excludeBookmarkTime: number; - }): { snappedTime: number; snapPoint: SnapPoint | null } => { + rawTime: MediaTime; + excludeBookmarkTime: MediaTime; + }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } => { const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (!shouldSnap) { return { snappedTime: rawTime, snapPoint: null }; @@ -162,11 +162,12 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const frameSnappedTime = - roundToFrame({ - time: Math.max(0, Math.min(mouseTime, duration)), - rate: activeProject.settings.fps, - }) ?? Math.max(0, Math.min(mouseTime, duration)); + const clampedTime = + mouseTime > duration ? duration : mouseTime; + const frameSnappedTime = roundFrameTime({ + time: clampedTime, + fps: activeProject.settings.fps, + }); const { snappedTime: initialTime } = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: bookmarkTime, @@ -193,10 +194,12 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const clampedTime = Math.max(0, Math.min(mouseTime, duration)); - const frameSnappedTime = - roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? - clampedTime; + const clampedTime = + mouseTime > duration ? duration : mouseTime; + const frameSnappedTime = roundFrameTime({ + time: clampedTime, + fps: activeProject.settings.fps, + }); const snapResult = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: dragState.bookmarkTime, @@ -234,10 +237,8 @@ export function useBookmarkDrag({ return; } - const clampedTime = Math.max( - 0, - Math.min(dragState.currentTime, duration), - ); + const clampedTime = + dragState.currentTime > duration ? duration : dragState.currentTime; editor.scenes.moveBookmark({ fromTime: dragState.bookmarkTime, diff --git a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx index 8e2b4bcf..2c254c84 100644 --- a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx +++ b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx @@ -5,6 +5,7 @@ import { type PreviewOverlaySourceResult, } from "@/preview/overlays"; import { getBookmarksActiveAtTime } from "./utils"; +import type { MediaTime } from "@/wasm"; export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { id: "bookmark-notes", @@ -15,7 +16,7 @@ export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { function BookmarkNotesOverlay({ bookmarks, }: { - bookmarks: Array<{ time: number; note: string; color?: string }>; + bookmarks: Array<{ time: MediaTime; note: string; color?: string }>; }) { return ( <div className="flex flex-col gap-1.5" aria-live="polite"> @@ -43,7 +44,7 @@ export function getBookmarkPreviewOverlaySource({ isVisible, }: { bookmarks: Bookmark[]; - time: number; + time: MediaTime; isVisible: boolean; }): PreviewOverlaySourceResult { const bookmarksWithNotes = getBookmarksActiveAtTime({ diff --git a/apps/web/src/timeline/bookmarks/snap-source.ts b/apps/web/src/timeline/bookmarks/snap-source.ts index 32a84baa..64d0c3c3 100644 --- a/apps/web/src/timeline/bookmarks/snap-source.ts +++ b/apps/web/src/timeline/bookmarks/snap-source.ts @@ -1,12 +1,13 @@ import type { Bookmark } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import type { MediaTime } from "@/wasm"; export function getBookmarkSnapPoints({ bookmarks, excludeBookmarkTime, }: { bookmarks: Bookmark[]; - excludeBookmarkTime?: number; + excludeBookmarkTime?: MediaTime; }): SnapPoint[] { return bookmarks.flatMap((bookmark) => { if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) { diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts index d05fe3a9..702e66c3 100644 --- a/apps/web/src/timeline/bookmarks/utils.ts +++ b/apps/web/src/timeline/bookmarks/utils.ts @@ -1,13 +1,13 @@ import type { Bookmark } from "@/timeline"; import type { FrameRate } from "opencut-wasm"; -import { roundToFrame } from "opencut-wasm"; +import { addMediaTime, roundFrameTime, type MediaTime } from "@/wasm"; function bookmarkTimeEqual({ bookmarkTime, frameTime, }: { - bookmarkTime: number; - frameTime: number; + bookmarkTime: MediaTime; + frameTime: MediaTime; }): boolean { return bookmarkTime === frameTime; } @@ -17,7 +17,7 @@ export function findBookmarkIndex({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): number { return bookmarks.findIndex((bookmark) => bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), @@ -29,7 +29,7 @@ export function isBookmarkAtTime({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): boolean { return bookmarks.some((bookmark) => bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), @@ -41,7 +41,7 @@ export function toggleBookmarkInArray({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark[] { const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); @@ -58,7 +58,7 @@ export function removeBookmarkFromArray({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark[] { return bookmarks.filter( (bookmark) => @@ -72,7 +72,7 @@ export function updateBookmarkInArray({ updates, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; updates: Partial<Omit<Bookmark, "time">>; }): Bookmark[] { const index = findBookmarkIndex({ bookmarks, frameTime }); @@ -92,8 +92,8 @@ export function moveBookmarkInArray({ toTime, }: { bookmarks: Bookmark[]; - fromTime: number; - toTime: number; + fromTime: MediaTime; + toTime: MediaTime; }): Bookmark[] { const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); if (index === -1) { @@ -110,10 +110,10 @@ export function getFrameTime({ time, fps, }: { - time: number; + time: MediaTime; fps: FrameRate; -}): number { - return roundToFrame({ time, rate: fps }) ?? time; +}): MediaTime { + return roundFrameTime({ time, fps }); } export function getBookmarkAtTime({ @@ -121,7 +121,7 @@ export function getBookmarkAtTime({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark | null { const index = findBookmarkIndex({ bookmarks, frameTime }); return index === -1 ? null : bookmarks[index]; @@ -132,13 +132,13 @@ export function getBookmarksActiveAtTime({ time, }: { bookmarks: Bookmark[]; - time: number; + time: MediaTime; }): Bookmark[] { return bookmarks.filter((bookmark) => { const start = bookmark.time; const end = bookmark.duration != null && bookmark.duration > 0 - ? start + bookmark.duration + ? addMediaTime({ a: start, b: bookmark.duration }) : start; return time >= start && time <= end; }); diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx deleted file mode 100644 index 1e47dcc8..00000000 --- a/apps/web/src/timeline/components/audio-tab.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { NumberField } from "@/components/ui/number-field"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { isSourceAudioSeparated } from "@/timeline/audio-separation"; -import { DEFAULTS } from "@/timeline/defaults"; -import { - clamp, - formatNumberForDisplay, - getFractionDigitsForStep, - isNearlyEqual, - snapToStep, -} from "@/utils/math"; -import type { AudioElement, VideoElement } from "@/timeline"; -import { resolveNumberAtTime } from "@/animation/values"; -import { useEditor } from "@/editor/use-editor"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { VolumeHighIcon } from "@hugeicons/core-free-icons"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; - -const VOLUME_STEP = 0.1; -const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); - -export function AudioTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedVolume = resolveNumberAtTime({ - baseValue: element.volume ?? DEFAULTS.element.volume, - animations: element.animations, - propertyPath: "volume", - localTime, - }); - const volume = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "volume", - localTime, - isPlayheadWithinElementRange, - displayValue: formatNumberForDisplay({ - value: resolvedVolume, - fractionDigits: VOLUME_FRACTION_DIGITS, - }), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) { - return null; - } - - return clamp({ - value: snapToStep({ value: parsed, step: VOLUME_STEP }), - min: VOLUME_DB_MIN, - max: VOLUME_DB_MAX, - }); - }, - valueAtPlayhead: resolvedVolume, - step: VOLUME_STEP, - buildBaseUpdates: ({ value }) => ({ - volume: value, - }), - }); - const isDefault = - volume.hasAnimatedKeyframes && isPlayheadWithinElementRange - ? isNearlyEqual({ - leftValue: resolvedVolume, - rightValue: DEFAULTS.element.volume, - }) - : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; - const isSeparated = - element.type === "video" && isSourceAudioSeparated({ element }); - - return ( - <> - {isSeparated && ( - <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3"> - <p className="text-sm">Audio has been separated.</p> - <Button - className="mt-3" - size="sm" - variant="secondary" - onClick={() => - editor.timeline.toggleSourceAudioSeparation({ - trackId, - elementId: element.id, - }) - } - > - Recover audio - </Button> - </div> - )} - <Section collapsible sectionKey={`${element.id}:audio`}> - <SectionHeader> - <SectionTitle>Audio</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField - label="Volume" - beforeLabel={ - <KeyframeToggle - isActive={volume.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle volume keyframe" - onToggle={volume.toggleKeyframe} - /> - } - > - <NumberField - icon={<HugeiconsIcon icon={VolumeHighIcon} />} - value={volume.displayValue} - onFocus={volume.onFocus} - onChange={volume.onChange} - onBlur={volume.onBlur} - dragSensitivity="slow" - scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} - onScrub={volume.scrubTo} - onScrubEnd={volume.commitScrub} - onReset={() => - volume.commitValue({ - value: DEFAULTS.element.volume, - }) - } - isDefault={isDefault} - suffix="dB" - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - </> - ); -} diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx index 1baac2bd..659bc5dc 100644 --- a/apps/web/src/timeline/components/audio-volume-line.tsx +++ b/apps/web/src/timeline/components/audio-volume-line.tsx @@ -3,13 +3,12 @@ import { useCallback, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useEditor } from "@/editor/use-editor"; -import { DEFAULTS } from "@/timeline/defaults"; import { getDbFromLinePos, getLinePosFromDb, } from "@/timeline/audio-display"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { hasAnimatedVolume } from "@/timeline/audio-state"; +import { getElementVolume, hasAnimatedVolume } from "@/timeline/audio-state"; import type { AudioElement } from "@/timeline/types"; import { clamp, @@ -60,10 +59,8 @@ export function AudioVolumeLine({ const editor = useEditor(); const surfaceRef = useRef<HTMLDivElement>(null); const activePointerIdRef = useRef<number | null>(null); - const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume); - const lastPreviewVolumeRef = useRef( - element.volume ?? DEFAULTS.element.volume, - ); + const startVolumeRef = useRef(getElementVolume({ element })); + const lastPreviewVolumeRef = useRef(getElementVolume({ element })); const hasChangedRef = useRef(false); const [isDragging, setIsDragging] = useState(false); const [tooltipClientPos, setTooltipClientPos] = useState<{ @@ -72,7 +69,7 @@ export function AudioVolumeLine({ } | null>(null); const hasAnimatedEnvelope = hasAnimatedVolume({ element }); - const currentVolume = element.volume ?? DEFAULTS.element.volume; + const currentVolume = getElementVolume({ element }); const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`; const volumeLabel = `${formatNumberForDisplay({ @@ -96,7 +93,7 @@ export function AudioVolumeLine({ { trackId, elementId: element.id, - updates: { volume: nextVolume }, + updates: { params: { volume: nextVolume } }, }, ], }); @@ -242,8 +239,7 @@ export function AudioVolumeLine({ )} style={{ top: lineTop }} /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- custom drag widget for clip volume; pointer events drive the interaction, the click/mousedown handlers are propagation-stoppers. The a11y-correct long-term shape is <input type="range"> with a custom thumb. */} <div className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto" style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }} diff --git a/apps/web/src/timeline/components/audio-waveform.tsx b/apps/web/src/timeline/components/audio-waveform.tsx index 7777bc09..87178f17 100644 --- a/apps/web/src/timeline/components/audio-waveform.tsx +++ b/apps/web/src/timeline/components/audio-waveform.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useResizeObserver } from "@/hooks/use-resize-observer"; import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme"; import { @@ -72,21 +73,19 @@ export function AudioWaveform({ const canvasRef = useRef<HTMLCanvasElement>(null); const containerRef = useRef<HTMLDivElement>(null); const summaryRef = useRef<SourceWaveformSummary | null>(null); - const gainSamplesRef = useRef<number[] | undefined>(gainSamples); - const pixelsPerSecondRef = useRef<number>(pixelsPerSecond); - const clipDurationSecRef = useRef<number>(clipDurationSec); - const retimeRef = useRef<RetimeConfig | undefined>(retime); - const sourceStartSecRef = useRef<number>(sourceStartSec); + const waveformConfigRef = useCommittedRef({ + gainSamples, + pixelsPerSecond, + clipDurationSec, + retime, + sourceStartSec, + color, + burnColor, + }); const scrollParentRef = useRef<HTMLElement | null>(null); const heightRef = useRef<number>(0); const lastRenderSignatureRef = useRef<string | null>(null); - gainSamplesRef.current = gainSamples; - pixelsPerSecondRef.current = pixelsPerSecond; - clipDurationSecRef.current = clipDurationSec; - retimeRef.current = retime; - sourceStartSecRef.current = sourceStartSec; - const clearCanvas = useCallback(() => { const canvas = canvasRef.current; if (!canvas) { @@ -142,13 +141,19 @@ export function AudioWaveform({ return; } + const { + gainSamples: gainSamplesValue, + pixelsPerSecond: pixelsPerSecondValue, + clipDurationSec: clipDurationSecValue, + retime: retimeValue, + sourceStartSec: sourceStartSecValue, + color: colorValue, + burnColor: burnColorValue, + } = waveformConfigRef.current; const dpr = window.devicePixelRatio || 1; const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr)); const canvasH = Math.max(1, Math.round(height * dpr)); const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP)); - const pixelsPerSecondValue = pixelsPerSecondRef.current; - const clipDurationSecValue = clipDurationSecRef.current; - const samples = gainSamplesRef.current; const renderSignature = JSON.stringify({ elementWidth, clipLeft, @@ -159,16 +164,16 @@ export function AudioWaveform({ barCount, dpr, clipDurationSec: clipDurationSecValue, - sourceStartSec: sourceStartSecRef.current, + sourceStartSec: sourceStartSecValue, pixelsPerSecond: pixelsPerSecondValue, - retime: retimeRef.current ?? null, + retime: retimeValue ?? null, summarySourceKey: summary.sourceKey, summarySampleRate: summary.sampleRate, summaryTotalSamples: summary.totalSamples, summaryBucketSize: summary.bucketSize, - gainSamples: samples ?? null, - color, - burnColor, + gainSamples: gainSamplesValue ?? null, + color: colorValue, + burnColor: burnColorValue, }); if (lastRenderSignatureRef.current === renderSignature) { return; @@ -190,8 +195,8 @@ export function AudioWaveform({ barCount, pixelsPerSecond: pixelsPerSecondValue, clipDurationSec: clipDurationSecValue, - sourceStartSec: sourceStartSecRef.current, - retime: retimeRef.current, + sourceStartSec: sourceStartSecValue, + retime: retimeValue, sampleRate: summary.sampleRate, maxSampleExclusive: summary.totalSamples, barStepPx: BAR_STEP, @@ -218,9 +223,9 @@ export function AudioWaveform({ Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue), ); const gain = - samples != null + gainSamplesValue != null ? sampleGainAtClipTime({ - samples, + samples: gainSamplesValue, clipTimeSec: clipCenterSec, clipDurationSec: clipDurationSecValue, }) @@ -243,7 +248,7 @@ export function AudioWaveform({ const deviceTop = Math.round((height - barH) * backingScaleY); const deviceHeight = Math.max(1, clipBottom - deviceTop); - ctx.fillStyle = color; + ctx.fillStyle = colorValue; ctx.fillRect( deviceLeft, deviceTop, @@ -253,7 +258,7 @@ export function AudioWaveform({ if (outputAmplitude > 1) { const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY)); - ctx.fillStyle = burnColor; + ctx.fillStyle = burnColorValue; ctx.fillRect( deviceLeft, deviceTop, @@ -262,7 +267,7 @@ export function AudioWaveform({ ); } } - }, [burnColor, clearCanvas, color]); + }, [clearCanvas, waveformConfigRef]); useEffect(() => { let isCancelled = false; @@ -296,7 +301,6 @@ export function AudioWaveform({ }; }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]); - // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change. useLayoutEffect(() => { drawVisible(); }, [ @@ -306,6 +310,8 @@ export function AudioWaveform({ clipDurationSec, retime, sourceStartSec, + color, + burnColor, ]); useEffect(() => { diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts index be2495e4..f68a4b72 100644 --- a/apps/web/src/timeline/components/drop-target.ts +++ b/apps/web/src/timeline/components/drop-target.ts @@ -3,7 +3,12 @@ import type { ComputeDropTargetParams, DropTarget } from "@/timeline"; import { resolveTrackPlacement } from "@/timeline/placement"; import { TIMELINE_TRACK_GAP_PX } from "./layout"; import { getTrackHeight } from "./track-layout"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + mediaTime, + type MediaTime, + roundMediaTime, + TICKS_PER_SECOND, +} from "@/wasm"; function findElementAtPosition({ mouseX, @@ -20,9 +25,11 @@ function findElementAtPosition({ pixelsPerSecond: number; zoomLevel: number; }): { elementId: string; trackId: string } | null { - const time = Math.round( - (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); + const time = mediaTime({ + ticks: Math.round( + (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, + ), + }); const track = tracks[trackIndex]; if (!track || !("elements" in track)) return null; @@ -82,7 +89,7 @@ const EMPTY_TARGET_ELEMENT = null; function fallbackNewTrackDropTarget({ xPosition, }: { - xPosition: number; + xPosition: MediaTime; }): DropTarget { return { trackIndex: 0, @@ -111,13 +118,16 @@ export function computeDropTarget({ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const mainTrackIndex = tracks.overlay.length; const xPosition = - typeof startTimeOverride === "number" + startTimeOverride !== undefined ? startTimeOverride : isExternalDrop ? playheadTime - : Math.round( - Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); + : mediaTime({ + ticks: Math.round( + Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * + TICKS_PER_SECOND, + ), + }); if (orderedTracks.length === 0) { const placementResult = resolveTrackPlacement({ @@ -221,7 +231,10 @@ export function computeDropTarget({ } if (placementResult.kind === "existingTrack") { - const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition; + const adjustedXPosition = + placementResult.adjustedStartTime !== undefined + ? roundMediaTime({ time: placementResult.adjustedStartTime }) + : xPosition; return { trackIndex: placementResult.trackIndex, diff --git a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx index 7d77b985..ff4219c5 100644 --- a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx +++ b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx @@ -2,6 +2,7 @@ import { useRef, useState, type PointerEvent } from "react"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { getBezierPoint } from "@/animation/bezier"; import type { NormalizedCubicBezier } from "@/animation/types"; import { cn } from "@/utils/ui"; @@ -87,9 +88,7 @@ export function BezierGraph({ const svgRef = useRef<SVGSVGElement>(null); const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null); const isShiftPressedRef = useShiftKey(); - const latestValueRef = useRef(value); - - latestValueRef.current = value; + const latestValueRef = useCommittedRef(value); function getPointerPosition({ event, diff --git a/apps/web/src/timeline/components/graph-editor/session.ts b/apps/web/src/timeline/components/graph-editor/session.ts index d7c6b001..c5d1c133 100644 --- a/apps/web/src/timeline/components/graph-editor/session.ts +++ b/apps/web/src/timeline/components/graph-editor/session.ts @@ -1,11 +1,12 @@ import { getCurveHandlesForNormalizedCubicBezier, getEditableScalarChannels, - getEasingModeForKind, getNormalizedCubicBezierForScalarSegment, getScalarKeyframeContext, updateScalarKeyframeCurve, } from "@/animation"; +import { getChannelsFromData } from "@/animation/channel-data"; +import { isScalarChannel } from "@/animation/interpolation"; import type { AnimationPath, ElementAnimations, @@ -143,12 +144,9 @@ function findKeyframeTime({ propertyPath: AnimationPath; keyframeId: string; }): number | null { - const binding = animations.bindings[propertyPath]; - if (!binding) return null; - - for (const component of binding.components) { - const channel = animations.channels[component.channelId]; - if (channel?.kind !== "scalar") continue; + const data = animations[propertyPath]; + for (const channel of getChannelsFromData({ data })) { + if (!channel || !isScalarChannel(channel)) continue; const key = channel.keys.find((k) => k.id === keyframeId); if (key !== undefined) return key.time; } @@ -300,8 +298,7 @@ function resolvePropertySelection({ } } - const { binding: resolvedBinding, channels: scalarChannels } = scalarResult; - const easingMode = getEasingModeForKind(resolvedBinding.kind); + const { easingMode, channels: scalarChannels } = scalarResult; const contexts = scalarChannels.flatMap((channel) => { const context = getScalarKeyframeContext({ animations: element.animations, diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index 88fb93b7..8edbb6fc 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -29,6 +29,8 @@ import { useState, type ReactNode, } from "react"; +import { useContainerSize } from "@/hooks/use-container-size"; +import type { MediaTime } from "@/wasm"; import type { ElementDragView, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; @@ -79,6 +81,7 @@ import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bott import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize"; import { useTimelineStore } from "@/timeline/timeline-store"; import { useEditor } from "@/editor/use-editor"; +import { useScrollPosition } from "@/timeline/hooks/use-scroll-position"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/actions"; @@ -132,7 +135,7 @@ export function Timeline() { [scene], ); const mainTrackId = scene?.tracks.main.id ?? null; - const seek = (time: number) => editor.playback.seek({ time }); + const seek = (time: MediaTime) => editor.playback.seek({ time }); const timelineRef = useRef<HTMLDivElement>(null); const timelineHeaderRef = useRef<HTMLDivElement>(null); @@ -147,15 +150,25 @@ export function Timeline() { const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( null, ); + const { width: tracksContainerWidth } = useContainerSize({ + containerRef: tracksContainerRef, + }); + const { height: timelineHeaderHeightValue } = useContainerSize({ + containerRef: timelineHeaderRef, + }); + const { viewportWidth: tracksViewportWidth } = useScrollPosition({ + scrollRef: tracksScrollRef, + }); const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { setCurrentSnapPoint(snapPoint); }, []); const timelineDuration = timeline.getTotalDuration() || 0; + const containerWidth = tracksContainerWidth || FALLBACK_CONTAINER_WIDTH; const minZoomLevel = getTimelineZoomMin({ duration: timelineDuration, - containerWidth: tracksContainerRef.current?.clientWidth, + containerWidth, }); const savedViewState = editor.project.getTimelineViewState(); @@ -289,12 +302,12 @@ export function Timeline() { const { dragView, handleElementMouseDown, handleElementClick } = useElementInteraction({ - zoomLevel, - tracksContainerRef, - tracksScrollRef, - snappingEnabled, - onSnapPointChange: handleSnapPointChange, - }); + zoomLevel, + tracksContainerRef, + tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); const isElementDragging = dragView.kind === "dragging"; const { @@ -357,8 +370,6 @@ export function Timeline() { }, }); - const containerWidth = - tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel, @@ -372,11 +383,8 @@ export function Timeline() { contentWidth + paddingPx, containerWidth, ); - const tracksViewportWidth = - tracksScrollRef.current?.clientWidth ?? - tracksContainerRef.current?.clientWidth ?? - containerWidth; - const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth; + const hasHorizontalScrollbar = + dynamicTimelineWidth > (tracksViewportWidth || containerWidth); useEdgeAutoScroll({ isActive: bookmarkDragState.isDragging, @@ -418,8 +426,7 @@ export function Timeline() { }); const timelineHeaderHeight = - (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + - TIMELINE_CONTENT_TOP_PADDING_PX || 0; + timelineHeaderHeightValue + TIMELINE_CONTENT_TOP_PADDING_PX; return ( <section @@ -449,10 +456,7 @@ export function Timeline() { ref={tracksContainerRef} > <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} + bounds={selectionBox?.bounds ?? null} /> <DragLine dropTarget={dropTarget} @@ -508,8 +512,7 @@ export function Timeline() { className="flex min-h-full flex-col" style={{ width: `${dynamicTimelineWidth}px` }} > - {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (tracks container background); direct-target clicks here originate box-select or clear selection. Keyboard control is global timeline shortcuts. */} <div className="relative shrink-0" style={{ @@ -777,8 +780,8 @@ function TimelineTrackRows({ const draggingElementIds = useMemo( () => dragView.kind === "dragging" - ? dragView.memberTimeOffsets - : (null as ReadonlyMap<string, number> | null), + ? dragView.memberTimeOffsets + : (null as ReadonlyMap<string, MediaTime> | null), [dragView], ); const sortedTracks = useMemo(() => { @@ -890,9 +893,10 @@ function TimelineGutter({ onMouseDown: (event: React.MouseEvent) => void; onClick: (event: React.MouseEvent) => void; }) { - // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system - // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system - return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />; + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (empty space below tracks); clicks here clear selection. Keyboard control is global timeline shortcuts. + <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} /> + ); } function TrackIcon({ track }: { track: TimelineTrack }) { diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index 35785332..1b549a15 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -31,6 +31,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import type { SelectionBoxBounds } from "@/selection/types"; import type { TimelineElement as TimelineElementType, TimelineTrack, @@ -46,10 +47,10 @@ import { getSourceAudioActionLabel, isSourceAudioSeparated, } from "@/timeline/audio-separation"; -import { buildWaveformGainSamples } from "@/timeline/audio-state"; +import { buildWaveformGainSamples, isElementMuted } from "@/timeline/audio-state"; import { getTimelinePixelsPerSecond } from "@/timeline"; import { buildWaveformSourceKey } from "@/media/waveform-summary"; -import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; import { getActionDefinition, type TAction, @@ -96,7 +97,7 @@ const PixelsPerSecondContext = createContext<number | null>(null); const THUMBNAIL_ASPECT_RATIO = 16 / 9; interface KeyframeIndicator { - time: number; + time: MediaTime; offsetPx: number; keyframes: SelectedKeyframeRef[]; } @@ -112,11 +113,11 @@ export function buildKeyframeIndicator({ keyframe: ElementKeyframe; trackId: string; elementId: string; - displayedStartTime: number; + displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; }): { - time: number; + time: MediaTime; offsetPx: number; keyframeRef: SelectedKeyframeRef; } { @@ -149,7 +150,7 @@ export function getKeyframeIndicators({ keyframes: ElementKeyframe[]; trackId: string; elementId: string; - displayedStartTime: number; + displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; elementWidth: number; @@ -158,7 +159,7 @@ export function getKeyframeIndicators({ return []; } - const keyframesByTime = new Map<number, KeyframeIndicator>(); + const keyframesByTime = new Map<MediaTime, KeyframeIndicator>(); for (const keyframe of keyframes) { const indicator = buildKeyframeIndicator({ keyframe, @@ -185,7 +186,7 @@ export function getKeyframeIndicators({ } export function getDisplayShortcut({ action }: { action: TAction }) { - const { defaultShortcuts } = getActionDefinition({ action }); + const defaultShortcuts = getActionDefinition({ action }).defaultShortcuts; if (!defaultShortcuts?.length) { return ""; } @@ -206,14 +207,14 @@ interface TimelineElementProps { track: TimelineTrack; side: "left" | "right"; }) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; dragView: ElementDragView; isDropTarget?: boolean; } @@ -263,7 +264,7 @@ export function TimelineElement({ : 0; const elementStartTime = isDragging && isBeingDragged - ? dragView.currentTime + dragTimeOffset + ? addMediaTime({ a: dragView.currentTime, b: dragTimeOffset }) : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = renderElement.duration; @@ -334,7 +335,7 @@ export function TimelineElement({ } }; - const isMuted = canElementHaveAudio(element) && element.muted === true; + const isMuted = canElementHaveAudio(element) && isElementMuted({ element }); const canToggleCurrentSourceAudio = selectedElements.length === 1 && isCurrentElementSelected && @@ -385,7 +386,7 @@ export function TimelineElement({ ? `${baseTrackHeight + expansionHeight}px` : "100%", transform: - isBeingDragged && isDragging + isDragging && isBeingDragged ? `translate3d(0, ${dragOffsetY}px, 0)` : undefined, }} @@ -530,14 +531,14 @@ function ElementInner({ isExpanded: boolean; baseTrackHeight: number; expandedContent: React.ReactNode; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; @@ -578,8 +579,8 @@ function ElementInner({ type="button" tabIndex={-1} className="absolute inset-0 size-full flex flex-col" - onClick={(event) => onElementClick(event, element)} - onMouseDown={(event) => onElementMouseDown(event, element)} + onClick={(event) => onElementClick({ event, element })} + onMouseDown={(event) => onElementMouseDown({ event, element })} > <div className={cn( @@ -664,7 +665,7 @@ function KeyframeIndicators({ }: { indicators: KeyframeIndicator[]; dragState: KeyframeDragState; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; onKeyframeMouseDown: (params: { event: React.MouseEvent; @@ -674,13 +675,13 @@ function KeyframeIndicators({ event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; + indicatorTime: MediaTime; }) => void; getVisualOffsetPx: (params: { - indicatorTime: number; + indicatorTime: MediaTime; indicatorOffsetPx: number; isBeingDragged: boolean; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; }) => number; }) { @@ -758,7 +759,7 @@ function ExpandedKeyframeLanes({ keyframes: ElementKeyframe[]; trackId: string; elementId: string; - displayedStartTime: number; + displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; keyframeDragState: KeyframeDragState; @@ -770,22 +771,20 @@ function ExpandedKeyframeLanes({ onLaneMouseDown: (event: React.MouseEvent) => void; onLaneClick: (event: React.MouseEvent) => void; selectionBox: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; + bounds: SelectionBoxBounds; } | null; isBoxSelecting: boolean; onKeyframeClick: (params: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; + indicatorTime: MediaTime; }) => void; getVisualOffsetPx: (params: { - indicatorTime: number; + indicatorTime: MediaTime; indicatorOffsetPx: number; isBeingDragged: boolean; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; }) => number; }) { @@ -808,8 +807,7 @@ function ExpandedKeyframeLanes({ ); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface - // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (keyframe lanes); keyboard control over keyframes is via global timeline shortcuts, not per-element focus. <div ref={containerRef} className="relative flex flex-col" @@ -893,14 +891,7 @@ function ExpandedKeyframeLanes({ </div> ); })} - {selectionBox && ( - <SelectionBox - startPos={selectionBox.startPos} - currentPos={selectionBox.currentPos} - containerRef={containerRef} - isActive={selectionBox.isActive} - /> - )} + {selectionBox && <SelectionBox bounds={selectionBox.bounds} />} </div> ); } @@ -917,7 +908,9 @@ function TextElementContent({ }) { return ( <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white">{element.content}</span> + <span className="truncate text-xs text-white"> + {typeof element.params.content === "string" ? element.params.content : ""} + </span> </div> ); } @@ -1073,7 +1066,7 @@ function EffectsButton({ editor.selection.setSelectedElements({ elements: [{ trackId: track.id, elementId: element.id }], }); - setActiveTab(element.type, "effects"); + setActiveTab({ elementType: element.type, tabId: "effects" }); }; return ( diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx index 4dd3329f..6d77789e 100644 --- a/apps/web/src/timeline/components/timeline-playhead.tsx +++ b/apps/web/src/timeline/components/timeline-playhead.tsx @@ -1,13 +1,22 @@ "use client"; import { useRef } from "react"; +import { useContainerSize } from "@/hooks/use-container-size"; import { getCenteredLineLeft, TIMELINE_INDICATOR_LINE_WIDTH_PX, timelineTimeToSnappedPixels, } from "@/timeline"; +import { useScrollPosition } from "@/timeline/hooks/use-scroll-position"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { useEditor } from "@/editor/use-editor"; import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout"; import { TIMELINE_LAYERS } from "./layers"; @@ -45,11 +54,14 @@ export function TimelinePlayhead({ tracksScrollRef, playheadRef, }); + const { height: timelineHeight } = useContainerSize({ containerRef: timelineRef }); + const { height: tracksHeight } = useContainerSize({ + containerRef: tracksScrollRef, + }); + const { scrollLeft } = useScrollPosition({ scrollRef: tracksScrollRef }); const timelineContainerHeight = - timelineRef.current?.clientHeight ?? - tracksScrollRef.current?.clientHeight ?? - 400; + timelineHeight || tracksHeight || 400; const totalHeight = Math.max( 0, timelineContainerHeight - @@ -61,7 +73,6 @@ export function TimelinePlayhead({ time: currentTime, zoomLevel, }); - const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0; const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft; @@ -72,17 +83,24 @@ export function TimelinePlayhead({ event.preventDefault(); const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ), + }); const direction = event.key === "ArrowRight" ? 1 : -1; const now = editor.playback.getCurrentTime(); - const nextTime = Math.max( - 0, - Math.min(duration, now + direction * ticksPerFrame), - ); + const nextTime = + direction > 0 + ? addMediaTime({ a: now, b: ticksPerFrame }) + : subMediaTime({ a: now, b: ticksPerFrame }); - editor.playback.seek({ time: nextTime }); + editor.playback.seek({ + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: duration < nextTime ? duration : nextTime, + }), + }); }; return ( diff --git a/apps/web/src/timeline/components/timeline-ruler.tsx b/apps/web/src/timeline/components/timeline-ruler.tsx index e2a379ee..ccacdae3 100644 --- a/apps/web/src/timeline/components/timeline-ruler.tsx +++ b/apps/web/src/timeline/components/timeline-ruler.tsx @@ -1,4 +1,4 @@ -import { type JSX, useLayoutEffect, useRef } from "react"; +import { type JSX } from "react"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { mediaTimeToSeconds } from "opencut-wasm"; import { TICKS_PER_SECOND } from "@/wasm"; @@ -51,20 +51,9 @@ export function TimelineRuler({ scrollRef: tracksScrollRef, }); - /** - * widens the virtualization buffer during zoom transitions. - * useScrollPosition lags one frame behind the scroll adjustment - * that useLayoutEffect applies after a zoom change. - */ - const prevZoomRef = useRef(zoomLevel); - const isZoomTransition = zoomLevel !== prevZoomRef.current; - const bufferPx = isZoomTransition - ? Math.max(200, (scrollLeft + viewportWidth) * 0.15) - : 200; - - useLayoutEffect(() => { - prevZoomRef.current = zoomLevel; - }, [zoomLevel]); + // Keep extra buffer because zoom layout and scroll position can briefly + // settle on different frames. + const bufferPx = Math.max(200, (scrollLeft + viewportWidth) * 0.15); const visibleStartTimeSeconds = Math.max( 0, diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx index 7608e6df..2603f511 100644 --- a/apps/web/src/timeline/components/timeline-track.tsx +++ b/apps/web/src/timeline/components/timeline-track.tsx @@ -62,7 +62,7 @@ export function TimelineTrackContent({ onTrackMouseDown?.(event); }} /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- spatial gesture surface; the wrapping <button> handles keyboard track selection, this <div> only forwards background clicks for box-select / deselect. */} <div className="relative h-full min-w-full" style={{ zIndex: TIMELINE_LAYERS.trackContent }} @@ -96,10 +96,10 @@ export function TimelineTrackContent({ onResizeStart={({ event, element, side }) => onResizeStart({ event, element, track, side }) } - onElementMouseDown={(event, element) => + onElementMouseDown={({ event, element }) => onElementMouseDown({ event, element, track }) } - onElementClick={(event, element) => + onElementClick={({ event, element }) => onElementClick({ event, element, track }) } dragView={dragView} diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts index cd03300e..a419101f 100644 --- a/apps/web/src/timeline/controllers/drag-drop-controller.ts +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -6,7 +6,7 @@ import { toElementDurationTicks, } from "@/timeline/creation"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { roundToFrame, type FrameRate } from "opencut-wasm"; +import type { FrameRate } from "opencut-wasm"; import { buildTextElement, buildGraphicElement, @@ -30,6 +30,7 @@ import type { import type { TimelineDragData } from "@/timeline/drag"; import type { MediaAsset } from "@/media/types"; import type { ProcessedMediaAsset } from "@/media/processing"; +import { roundFrameTime, type MediaTime } from "@/wasm"; // --- Config --- @@ -41,7 +42,7 @@ export interface DragDropConfig { getActiveProjectFps: () => FrameRate | null; getActiveProjectId: () => string | null; getSceneTracks: () => SceneTracks; - getCurrentPlayheadTime: () => number; + getCurrentPlayheadTime: () => MediaTime; getMediaAssets: () => MediaAsset[]; dragSource: TimelineDragSource; addMediaAsset: (args: { @@ -116,7 +117,7 @@ function getDurationForDrag({ }: { dragData: TimelineDragData; mediaAssets: MediaAsset[]; -}): number { +}): MediaTime { if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION; const media = mediaAssets.find((asset) => asset.id === dragData.id); return toElementDurationTicks({ seconds: media?.duration }); @@ -225,8 +226,7 @@ export class DragDropController { const fps = this.config.getActiveProjectFps(); target.xPosition = fps - ? (roundToFrame({ time: target.xPosition, rate: fps }) ?? - target.xPosition) + ? roundFrameTime({ time: target.xPosition, fps }) : target.xPosition; this.setOver({ dropTarget: target, elementType }); @@ -326,7 +326,10 @@ export class DragDropController { trackType: TrackType; }): void { if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + const addTrackCmd = new AddTrackCommand({ + type: trackType, + index: target.trackIndex, + }); this.config.executeCommand( new BatchCommand([ addTrackCmd, @@ -382,7 +385,10 @@ export class DragDropController { dragData: Extract<TimelineDragData, { type: "text" }>; }): void { const element = buildTextElement({ - raw: { name: dragData.name ?? "", content: dragData.content ?? "" }, + raw: { + name: dragData.name ?? "", + params: { content: dragData.content ?? "" }, + }, startTime: target.xPosition, }); this.insertAtTarget({ element, target, trackType: "text" }); diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts index cc19daaa..5afc3a93 100644 --- a/apps/web/src/timeline/controllers/element-interaction-controller.ts +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -7,9 +7,17 @@ import { type MoveGroup, } from "@/timeline/group-move"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + maxMediaTime, + type MediaTime, + mediaTime, + roundFrameTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame, type FrameRate } from "opencut-wasm"; +import type { FrameRate } from "opencut-wasm"; import { computeDropTarget } from "@/timeline/components/drop-target"; import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; @@ -52,7 +60,7 @@ export interface ElementSelectionApi { } export interface PlaybackReader { - getCurrentTime: () => number; + getCurrentTime: () => MediaTime; } export interface TimelineOps { @@ -86,8 +94,8 @@ interface MousedownSnapshot { readonly origin: Point; readonly elementId: string; readonly trackId: string; - readonly startElementTime: number; - readonly clickOffsetTime: number; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; readonly selectedElements: readonly ElementRef[]; } @@ -100,7 +108,7 @@ interface DragProgress { // indicator, drop-line, commit path) see the same entity every frame // instead of a churning UUID. reservedNewTrackIds: readonly string[]; - currentTime: number; + currentTime: MediaTime; currentMouseX: number; currentMouseY: number; groupMoveResult: GroupMoveResult | null; @@ -116,20 +124,27 @@ const IDLE_VIEW: ElementDragView = { kind: "idle" }; // --- Pure helpers --- -function pixelToClickOffsetTime( - clientX: number, - elementRect: DOMRect, - zoomLevel: number, -): number { +function pixelToClickOffsetTime({ + clientX, + elementRect, + zoomLevel, +}: { + clientX: number; + elementRect: DOMRect; + zoomLevel: number; +}): MediaTime { const clickOffsetX = clientX - elementRect.left; const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); } -function verticalDirection( - startMouseY: number, - currentMouseY: number, -): "up" | "down" | null { +function verticalDirection({ + startMouseY, + currentMouseY, +}: { + startMouseY: number; + currentMouseY: number; +}): "up" | "down" | null { if (currentMouseY < startMouseY) return "up"; if (currentMouseY > startMouseY) return "down"; return null; @@ -139,7 +154,13 @@ function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] { return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; } -function movedPastDragThreshold(current: Point, origin: Point): boolean { +function movedPastDragThreshold({ + current, + origin, +}: { + current: Point; + origin: Point; +}): boolean { return ( Math.abs(current.x - origin.x) > TIMELINE_DRAG_THRESHOLD_PX || Math.abs(current.y - origin.y) > TIMELINE_DRAG_THRESHOLD_PX @@ -156,17 +177,20 @@ function frameSnappedMouseTime({ clientX: number; scrollContainer: HTMLDivElement; zoomLevel: number; - clickOffsetTime: number; + clickOffsetTime: MediaTime; fps: FrameRate; -}): number { +}): MediaTime { const mouseTime = getMouseTimeFromClientX({ clientX, containerRect: scrollContainer.getBoundingClientRect(), zoomLevel, scrollLeft: scrollContainer.scrollLeft, }); - const adjusted = Math.max(0, mouseTime - clickOffsetTime); - return roundToFrame({ time: adjusted, rate: fps }) ?? adjusted; + const adjusted = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ a: mouseTime, b: clickOffsetTime }), + }); + return roundFrameTime({ time: adjusted, fps }); } function resolveDropTarget({ @@ -187,7 +211,7 @@ function resolveDropTarget({ tracks: SceneTracks; viewport: ViewportAdapter; zoomLevel: number; - snappedTime: number; + snappedTime: MediaTime; verticalDragDirection: "up" | "down" | null; }): DropTarget | null { const containerRect = viewport @@ -231,7 +255,7 @@ function resolveGroupMoveForDrop({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; dropTarget: DropTarget; reservedNewTrackIds: readonly string[]; }): GroupMoveResult | null { @@ -286,7 +310,7 @@ export class ElementInteractionController { get view(): ElementDragView { if (this.session.kind !== "dragging") return IDLE_VIEW; const { mousedown, drag } = this.session; - const memberTimeOffsets = new Map<string, number>(); + const memberTimeOffsets = new Map<string, MediaTime>(); for (const member of drag.moveGroup.members) { memberTimeOffsets.set(member.elementId, member.timeOffset); } @@ -363,11 +387,11 @@ export class ElementInteractionController { elementId: element.id, trackId: track.id, startElementTime: element.startTime, - clickOffsetTime: pixelToClickOffsetTime( - event.clientX, - event.currentTarget.getBoundingClientRect(), - this.deps.viewport.getZoomLevel(), - ), + clickOffsetTime: pixelToClickOffsetTime({ + clientX: event.clientX, + elementRect: event.currentTarget.getBoundingClientRect(), + zoomLevel: this.deps.viewport.getZoomLevel(), + }), selectedElements, }, }; @@ -426,10 +450,13 @@ export class ElementInteractionController { this.notify(); } - private snapResult( - frameSnappedTime: number, - group: MoveGroup, - ): { snappedTime: number; snapPoint: SnapPoint | null } { + private snapResult({ + frameSnappedTime, + group, + }: { + frameSnappedTime: MediaTime; + group: MoveGroup; + }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } { const { snap, input, scene, viewport, playback } = this.deps; if (!snap.isEnabled() || input.isShiftHeld()) { @@ -461,7 +488,7 @@ export class ElementInteractionController { clientY: number; mousedown: MousedownSnapshot; drag: DragProgress; - snappedTime: number; + snappedTime: MediaTime; }): void { const { scene, viewport } = this.deps; const tracks = scene.getTracks(); @@ -476,7 +503,10 @@ export class ElementInteractionController { viewport, zoomLevel, snappedTime, - verticalDragDirection: verticalDirection(mousedown.origin.y, clientY), + verticalDragDirection: verticalDirection({ + startMouseY: mousedown.origin.y, + currentMouseY: clientY, + }), }); const nextGroupMoveResult = anchorDropTarget @@ -532,7 +562,12 @@ export class ElementInteractionController { clientY: number; scrollContainer: HTMLDivElement; }): void { - if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + if ( + !movedPastDragThreshold({ + current: { x: clientX, y: clientY }, + origin: mousedown.origin, + }) + ) { return; } @@ -557,10 +592,10 @@ export class ElementInteractionController { clickOffsetTime: mousedown.clickOffsetTime, fps, }); - const { snappedTime, snapPoint } = this.snapResult( + const { snappedTime, snapPoint } = this.snapResult({ frameSnappedTime, - moveGroup, - ); + group: moveGroup, + }); // Ensure the anchor is selected before we render the drag — covers the // case where the selection store hasn't committed the mousedown-time @@ -621,10 +656,10 @@ export class ElementInteractionController { clickOffsetTime: mousedown.clickOffsetTime, fps, }); - const { snappedTime, snapPoint } = this.snapResult( + const { snappedTime, snapPoint } = this.snapResult({ frameSnappedTime, - drag.moveGroup, - ); + group: drag.moveGroup, + }); drag.currentTime = snappedTime; drag.currentMouseX = clientX; @@ -655,7 +690,12 @@ export class ElementInteractionController { // If the drag returned within the click threshold of its origin, treat // this as a cancel rather than a commit — the user dragged then put the // element back. - if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + if ( + !movedPastDragThreshold({ + current: { x: clientX, y: clientY }, + origin: mousedown.origin, + }) + ) { this.lastGestureWasDrag = false; this.finishSession(); return; diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts index 51c60dee..e3a9cd96 100644 --- a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts +++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts @@ -1,7 +1,16 @@ import type { MouseEvent as ReactMouseEvent } from "react"; -import { roundToFrame, snappedSeekTime, type FrameRate } from "opencut-wasm"; +import type { FrameRate } from "opencut-wasm"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + clampMediaTime, + type MediaTime, + mediaTime, + roundFrameTicks, + snapSeekMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; import { timelineTimeToSnappedPixels } from "@/timeline"; import { getKeyframeById } from "@/animation"; @@ -48,7 +57,7 @@ export interface KeyframeDragConfig { zoomLevel: number; getFps: () => FrameRate | null; element: TimelineElement; - displayedStartTime: number; + displayedStartTime: MediaTime; selectedKeyframes: SelectedKeyframeRef[]; isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean; setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void; @@ -62,8 +71,8 @@ export interface KeyframeDragConfig { isAdditive: boolean; }) => void; executeCommand: (command: Command) => void; - seek: (args: { time: number }) => void; - getTotalDuration: () => number; + seek: (args: { time: MediaTime }) => void; + getTotalDuration: () => MediaTime; } export interface KeyframeDragConfigRef { @@ -161,7 +170,7 @@ export class KeyframeDragController { event: ReactMouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; + indicatorTime: MediaTime; }): void { event.stopPropagation(); @@ -174,14 +183,18 @@ export class KeyframeDragController { const { displayedStartTime, getFps, getTotalDuration, seek } = this.config; const fps = getFps(); + const absoluteIndicatorTime = addMediaTime({ + a: displayedStartTime, + b: indicatorTime, + }); const seekTime = fps != null - ? (snappedSeekTime({ - time: displayedStartTime + indicatorTime, + ? snapSeekMediaTime({ + time: absoluteIndicatorTime, duration: getTotalDuration(), - rate: fps, - }) ?? displayedStartTime + indicatorTime) - : displayedStartTime + indicatorTime; + fps, + }) + : absoluteIndicatorTime; seek({ time: seekTime }); if (event.shiftKey) { @@ -206,24 +219,23 @@ export class KeyframeDragController { displayedStartTime, elementLeft, }: { - indicatorTime: number; + indicatorTime: MediaTime; indicatorOffsetPx: number; isBeingDragged: boolean; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; }): number { if (!isBeingDragged || this.session.kind !== "active") return indicatorOffsetPx; - const clampedTime = Math.max( - 0, - Math.min( - this.config.element.duration, - indicatorTime + this.session.deltaTicks, - ), - ); + const deltaTime = mediaTime({ ticks: this.session.deltaTicks }); + const clampedTime = clampMediaTime({ + time: addMediaTime({ a: indicatorTime, b: deltaTime }), + min: ZERO_MEDIA_TIME, + max: this.config.element.duration, + }); return ( timelineTimeToSnappedPixels({ - time: displayedStartTime + clampedTime, + time: addMediaTime({ a: displayedStartTime, b: clampedTime }), zoomLevel: this.config.zoomLevel, }) - elementLeft ); @@ -270,10 +282,14 @@ export class KeyframeDragController { elementId: ref.elementId, propertyPath: ref.propertyPath, keyframeId: ref.keyframeId, - nextTime: Math.max( - 0, - Math.min(element.duration, keyframe.time + deltaTicks), - ), + nextTime: clampMediaTime({ + time: addMediaTime({ + a: keyframe.time, + b: mediaTime({ ticks: deltaTicks }), + }), + min: ZERO_MEDIA_TIME, + max: element.duration, + }), }), ]; }); @@ -313,8 +329,7 @@ export class KeyframeDragController { ((clientX - this.session.startMouseX) / pixelsPerSecond) * TICKS_PER_SECOND, ); - this.session.deltaTicks = - roundToFrame({ time: rawDeltaTicks, rate: fps }) ?? rawDeltaTicks; + this.session.deltaTicks = roundFrameTicks({ ticks: rawDeltaTicks, fps }); this.notify(); } diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts index d2b7d82f..e914ff21 100644 --- a/apps/web/src/timeline/controllers/playhead-controller.ts +++ b/apps/web/src/timeline/controllers/playhead-controller.ts @@ -1,6 +1,11 @@ import type { MouseEvent as ReactMouseEvent } from "react"; -import { snappedSeekTime, type FrameRate } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/wasm"; +import type { FrameRate } from "opencut-wasm"; +import { + mediaTime, + snapSeekMediaTime, + TICKS_PER_SECOND, + type MediaTime, +} from "@/wasm"; import { buildTimelineSnapPoints, getTimelineSnapThresholdInTicks, @@ -26,7 +31,7 @@ interface ScrubSession { /** True once the mouse has moved during a ruler drag. */ hasMoved: boolean; /** Most recent frame-snapped time set by scrub(). */ - currentTime: number | null; + currentTime: MediaTime | null; } type Session = { kind: "idle" } | ScrubSession; @@ -35,7 +40,7 @@ type Session = { kind: "idle" } | ScrubSession; export interface PlayheadConfig { zoomLevel: number; - duration: number; + duration: MediaTime; getActiveProjectFps: () => FrameRate | null; isShiftHeld: () => boolean; getIsPlaying: () => boolean; @@ -45,12 +50,12 @@ export interface PlayheadConfig { getPlayheadEl: () => HTMLDivElement | null; getSceneTracks: () => SceneTracks; getSceneBookmarks: () => Bookmark[]; - seek: (time: number) => void; + seek: (time: MediaTime) => void; setScrubbing: (isScrubbing: boolean) => void; setTimelineViewState: (viewState: { zoomLevel: number; scrollLeft: number; - playheadTime: number; + playheadTime: MediaTime; }) => void; } @@ -69,8 +74,8 @@ function pixelToTime({ clientX: number; rulerEl: HTMLDivElement; zoomLevel: number; - duration: number; -}): number { + duration: MediaTime; +}): MediaTime { const rulerRect = rulerEl.getBoundingClientRect(); const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel }); const clampedX = Math.max( @@ -84,7 +89,7 @@ function pixelToTime({ clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), ), ); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); } // --- Controller --- @@ -159,7 +164,7 @@ export class PlayheadController { * Called on scroll and playback events to avoid React re-renders * during animation frame updates. */ - updatePlayheadLeft(time: number): void { + updatePlayheadLeft(time: MediaTime): void { const playheadEl = this.config.getPlayheadEl(); if (!playheadEl) return; @@ -175,7 +180,7 @@ export class PlayheadController { * Updates the playhead position and auto-scrolls to keep the playhead * visible during playback. */ - handlePlaybackUpdate(time: number): void { + handlePlaybackUpdate(time: MediaTime): void { this.updatePlayheadLeft(time); // Auto-scroll only during playback, not while scrubbing. @@ -244,8 +249,7 @@ export class PlayheadController { zoomLevel, duration, }); - const frameTime = - snappedSeekTime({ time: rawTime, duration, rate: fps }) ?? rawTime; + const frameTime = snapSeekMediaTime({ time: rawTime, duration, fps }); const time = (() => { if (!isElementSnappingEnabled || this.config.isShiftHeld()) diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts index cdf502a1..a1098915 100644 --- a/apps/web/src/timeline/controllers/resize-controller.ts +++ b/apps/web/src/timeline/controllers/resize-controller.ts @@ -1,6 +1,14 @@ import type { MouseEvent as ReactMouseEvent } from "react"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + type MediaTime, + mediaTime, + minMediaTime, + subMediaTime, + TICKS_PER_SECOND, +} from "@/wasm"; import { computeGroupResize, type GroupResizeMember, @@ -46,7 +54,7 @@ export interface ResizeConfig { snappingEnabled: boolean; isShiftHeld: () => boolean; getSceneTracks: () => SceneTracks; - getCurrentPlayheadTime: () => number; + getCurrentPlayheadTime: () => MediaTime; getActiveProjectFps: () => FrameRate | null; selectedElements: ElementRef[]; discardPreview: () => void; @@ -87,14 +95,32 @@ export function buildResizeMembers({ (el) => !selectedElementIds.has(el.id), ); const leftNeighborBound = otherElements - .filter((el) => el.startTime + el.duration <= element.startTime) - .reduce( - (bound, el) => Math.max(bound, el.startTime + el.duration), - -Infinity, - ); + .filter( + (el) => + addMediaTime({ a: el.startTime, b: el.duration }) <= + element.startTime, + ) + .reduce<MediaTime | null>((bound, el) => { + const elementEnd = addMediaTime({ + a: el.startTime, + b: el.duration, + }); + return bound === null + ? elementEnd + : maxMediaTime({ a: bound, b: elementEnd }); + }, null); const rightNeighborBound = otherElements - .filter((el) => el.startTime >= element.startTime + element.duration) - .reduce((bound, el) => Math.min(bound, el.startTime), Infinity); + .filter( + (el) => + el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }), + ) + .reduce<MediaTime | null>( + (bound, el) => + bound === null + ? el.startTime + : minMediaTime({ a: bound, b: el.startTime }), + null, + ); return [ { @@ -237,12 +263,18 @@ export class ResizeController { this.notify(); } - private snappedDelta(session: ResizeSession, rawDeltaTicks: number): number { + private snappedDelta({ + session, + rawDeltaTime, + }: { + session: ResizeSession; + rawDeltaTime: MediaTime; + }): MediaTime { const { snappingEnabled, isShiftHeld, zoomLevel } = this.config; if (!snappingEnabled || isShiftHeld()) { this.config.onSnapPointChange?.(null); - return rawDeltaTicks; + return rawDeltaTime; } const tracks = this.config.getSceneTracks(); @@ -264,15 +296,15 @@ export class ResizeController { let closestSnapPoint: SnapPoint | null = null; let closestSnapDistance = Infinity; - let deltaTicks = rawDeltaTicks; + let deltaTime = rawDeltaTime; for (const member of session.members) { const baseEdgeTime = session.side === "left" ? member.startTime - : member.startTime + member.duration; + : addMediaTime({ a: member.startTime, b: member.duration }); const snapResult = resolveTimelineSnap({ - targetTime: baseEdgeTime + rawDeltaTicks, + targetTime: addMediaTime({ a: baseEdgeTime, b: rawDeltaTime }), snapPoints, maxSnapDistance, }); @@ -282,28 +314,30 @@ export class ResizeController { ) { closestSnapDistance = snapResult.snapDistance; closestSnapPoint = snapResult.snapPoint; - deltaTicks = snapResult.snappedTime - baseEdgeTime; + deltaTime = subMediaTime({ a: snapResult.snappedTime, b: baseEdgeTime }); } } this.config.onSnapPointChange?.(closestSnapPoint); - return deltaTicks; + return deltaTime; } private handleMouseMove({ clientX }: MouseEvent): void { if (this.session.kind !== "active") return; const session = this.session; - const rawDeltaTicks = Math.round( - ((clientX - session.startX) / - (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) * - TICKS_PER_SECOND, - ); - const deltaTicks = this.snappedDelta(session, rawDeltaTicks); + const rawDeltaTime = mediaTime({ + ticks: Math.round( + ((clientX - session.startX) / + (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) * + TICKS_PER_SECOND, + ), + }); + const deltaTime = this.snappedDelta({ session, rawDeltaTime }); const result = computeGroupResize({ members: session.members, side: session.side, - deltaTicks, + deltaTime, fps: session.fps, }); diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts index f32c855e..67dc6e96 100644 --- a/apps/web/src/timeline/controllers/seek-controller.ts +++ b/apps/web/src/timeline/controllers/seek-controller.ts @@ -1,7 +1,7 @@ import type { MouseEvent as ReactMouseEvent } from "react"; -import { snappedSeekTime, type FrameRate } from "opencut-wasm"; +import type { FrameRate } from "opencut-wasm"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, snapSeekMediaTime, TICKS_PER_SECOND, type MediaTime } from "@/wasm"; type SeekSource = "ruler" | "tracks"; @@ -17,7 +17,7 @@ type Session = { kind: "idle" } | PendingSeekSession; export interface SeekConfig { zoomLevel: number; - duration: number; + duration: MediaTime; isSelecting: boolean; getPlayheadEl: () => HTMLDivElement | null; getTrackLabelsEl: () => HTMLDivElement | null; @@ -25,11 +25,11 @@ export interface SeekConfig { getTracksScrollEl: () => HTMLDivElement | null; getActiveProjectFps: () => FrameRate | null; clearSelectedElements: () => void; - seek: (time: number) => void; + seek: (time: MediaTime) => void; setTimelineViewState: (viewState: { zoomLevel: number; scrollLeft: number; - playheadTime: number; + playheadTime: MediaTime; }) => void; } @@ -46,8 +46,8 @@ function pixelToTime({ clientX: number; scrollContainer: HTMLDivElement; zoomLevel: number; - duration: number; -}): number { + duration: MediaTime; +}): MediaTime { const rect = scrollContainer.getBoundingClientRect(); const mouseX = clientX - rect.left; const scrollLeft = scrollContainer.scrollLeft; @@ -60,7 +60,7 @@ function pixelToTime({ ), ); - return Math.round(rawTimeSeconds * TICKS_PER_SECOND); + return mediaTime({ ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND) }); } function isClickGesture({ @@ -193,11 +193,11 @@ export class SeekController { const fps = this.config.getActiveProjectFps(); const time = fps != null - ? (snappedSeekTime({ + ? snapSeekMediaTime({ time: rawTime, duration: this.config.duration, - rate: fps, - }) ?? rawTime) + fps, + }) : rawTime; this.config.seek(time); diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts index 8e0af4fe..91147354 100644 --- a/apps/web/src/timeline/controllers/zoom-controller.ts +++ b/apps/web/src/timeline/controllers/zoom-controller.ts @@ -3,6 +3,7 @@ import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/i import { timelineTimeToPixels } from "@/timeline/pixel-utils"; import { TIMELINE_ZOOM_MAX } from "@/timeline/scale"; import { zoomToSlider } from "@/timeline/zoom-utils"; +import type { MediaTime } from "@/wasm"; type ZoomUpdater = number | ((prev: number) => number); @@ -11,12 +12,12 @@ export interface ZoomConfig { getContainerEl: () => HTMLDivElement | null; getTracksScrollEl: () => HTMLDivElement | null; getRulerScrollEl: () => HTMLDivElement | null; - getCurrentPlayheadTime: () => number; - seek: (time: number) => void; + getCurrentPlayheadTime: () => MediaTime; + seek: (time: MediaTime) => void; setTimelineViewState: (viewState: { zoomLevel: number; scrollLeft: number; - playheadTime: number; + playheadTime: MediaTime; }) => void; } @@ -24,7 +25,13 @@ export interface ZoomConfigRef { readonly current: ZoomConfig; } -function clampZoom(zoomLevel: number, minZoom: number): number { +function clampZoom({ + zoomLevel, + minZoom, +}: { + zoomLevel: number; + minZoom: number; +}): number { return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel)); } @@ -48,7 +55,7 @@ export class ZoomController { const minZoom = this.config.minZoom; this.zoomLevelValue = deps.initialZoom !== undefined - ? clampZoom(deps.initialZoom, minZoom) + ? clampZoom({ zoomLevel: deps.initialZoom, minZoom }) : minZoom; this.previousZoom = this.zoomLevelValue; this.hasInitialized = deps.initialZoom !== undefined; @@ -88,7 +95,10 @@ export class ZoomController { typeof zoomLevelOrUpdater === "function" ? zoomLevelOrUpdater(this.zoomLevelValue) : zoomLevelOrUpdater; - const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom); + const nextZoom = clampZoom({ + zoomLevel: nextZoomRaw, + minZoom: this.config.minZoom, + }); if (nextZoom === this.zoomLevelValue) return; this.zoomLevelValue = nextZoom; @@ -114,10 +124,16 @@ export class ZoomController { } } - reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void { + reconcileInitialAndMinZoom({ + minZoom, + initialZoom, + }: { + minZoom: number; + initialZoom?: number; + }): void { if (initialZoom !== undefined && !this.hasInitialized) { this.hasInitialized = true; - this.setZoomLevel(clampZoom(initialZoom, minZoom)); + this.setZoomLevel(clampZoom({ zoomLevel: initialZoom, minZoom })); return; } @@ -249,7 +265,7 @@ export class ZoomController { return () => observer.disconnect(); } - restoreInitialPlayheadIfNeeded(initialPlayheadTime?: number): void { + restoreInitialPlayheadIfNeeded(initialPlayheadTime?: MediaTime): void { if (initialPlayheadTime === undefined) return; if (this.hasRestoredPlayhead) return; diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts index 2dec3186..0f28af56 100644 --- a/apps/web/src/timeline/creation.ts +++ b/apps/web/src/timeline/creation.ts @@ -1,20 +1,17 @@ -import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { mediaTime, mediaTimeFromSeconds, TICKS_PER_SECOND } from "@/wasm"; -export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND; +export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({ + ticks: 5 * TICKS_PER_SECOND, +}); -/** - * Resolves the duration in ticks that a newly created timeline element - * should have, given an optional source duration in seconds. - * - * Falls back to {@link DEFAULT_NEW_ELEMENT_DURATION} when the source has - * no known duration (e.g. stickers, graphics, text, or media whose - * probe failed). - */ export function toElementDurationTicks({ seconds, }: { seconds: number | null | undefined; -}): number { - if (seconds == null) return DEFAULT_NEW_ELEMENT_DURATION; - return Math.round(seconds * TICKS_PER_SECOND); +}) { + if (seconds == null) { + return DEFAULT_NEW_ELEMENT_DURATION; + } + + return mediaTimeFromSeconds({ seconds }); } diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts index 06639e01..d32fb649 100644 --- a/apps/web/src/timeline/defaults.ts +++ b/apps/web/src/timeline/defaults.ts @@ -1,6 +1,7 @@ import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import type { TTimelineViewState } from "@/project/types"; import type { BlendMode, Transform } from "@/rendering"; +import { ZERO_MEDIA_TIME } from "@/wasm"; import type { TextElement } from "./types"; const defaultTransform: Transform = { @@ -30,32 +31,42 @@ const defaultTextBackground = { const defaultTextElement: Omit<TextElement, "id"> = { type: "text", name: "Text", - content: "Default text", - fontSize: 15, - fontFamily: "Arial", - color: "#ffffff", - background: { ...defaultTextBackground }, - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - letterSpacing: defaultTextLetterSpacing, - lineHeight: defaultTextLineHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - transform: { - ...defaultTransform, - position: { ...defaultTransform.position }, + startTime: ZERO_MEDIA_TIME, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { + content: "Default text", + fontSize: 15, + fontFamily: "Arial", + color: "#ffffff", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + letterSpacing: defaultTextLetterSpacing, + lineHeight: defaultTextLineHeight, + "background.enabled": defaultTextBackground.enabled, + "background.color": defaultTextBackground.color, + "background.cornerRadius": defaultTextBackground.cornerRadius, + "background.paddingX": defaultTextBackground.paddingX, + "background.paddingY": defaultTextBackground.paddingY, + "background.offsetX": defaultTextBackground.offsetX, + "background.offsetY": defaultTextBackground.offsetY, + "transform.positionX": defaultTransform.position.x, + "transform.positionY": defaultTransform.position.y, + "transform.scaleX": defaultTransform.scaleX, + "transform.scaleY": defaultTransform.scaleY, + "transform.rotate": defaultTransform.rotate, + opacity: defaultOpacity, + blendMode: defaultBlendMode, }, - opacity: defaultOpacity, }; const defaultTimelineViewState: TTimelineViewState = { zoomLevel: 1, scrollLeft: 0, - playheadTime: 0, + playheadTime: ZERO_MEDIA_TIME, }; export const DEFAULTS = { diff --git a/apps/web/src/timeline/drag-utils.ts b/apps/web/src/timeline/drag-utils.ts index a731d7a0..ad8d8001 100644 --- a/apps/web/src/timeline/drag-utils.ts +++ b/apps/web/src/timeline/drag-utils.ts @@ -1,5 +1,5 @@ import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; export function getMouseTimeFromClientX({ clientX, @@ -11,11 +11,13 @@ export function getMouseTimeFromClientX({ containerRect: DOMRect; zoomLevel: number; scrollLeft: number; -}): number { +}): MediaTime { const mouseX = clientX - containerRect.left + scrollLeft; const seconds = Math.max( 0, mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), ); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ + ticks: Math.round(seconds * TICKS_PER_SECOND), + }); } diff --git a/apps/web/src/timeline/element-snap-source.ts b/apps/web/src/timeline/element-snap-source.ts index ec1e5f65..26aa86f6 100644 --- a/apps/web/src/timeline/element-snap-source.ts +++ b/apps/web/src/timeline/element-snap-source.ts @@ -1,5 +1,6 @@ import type { SceneTracks } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import { addMediaTime } from "@/wasm"; export function getElementEdgeSnapPoints({ tracks, @@ -25,7 +26,7 @@ export function getElementEdgeSnapPoints({ trackId: track.id, }, { - time: element.startTime + element.duration, + time: addMediaTime({ a: element.startTime, b: element.duration }), type: "element-end", elementId: element.id, trackId: track.id, diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts index 676bc823..10c759d9 100644 --- a/apps/web/src/timeline/element-utils.ts +++ b/apps/web/src/timeline/element-utils.ts @@ -11,7 +11,6 @@ import { type CreateStickerElement, type CreateUploadAudioElement, type CreateLibraryAudioElement, - type TextBackground, type TextElement, type SceneTracks, type TimelineElement, @@ -28,7 +27,12 @@ import type { MediaType } from "@/media/types"; import { buildDefaultEffectInstance } from "@/effects"; import { buildDefaultGraphicInstance } from "@/graphics"; import type { ParamValues } from "@/params"; +import { + buildDefaultParamValues, + getBuiltInElementParams, +} from "@/params/registry"; import { capitalizeFirstLetter } from "@/utils/string"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export function canElementHaveAudio( element: TimelineElement, @@ -86,20 +90,12 @@ export function requiresMediaId({ ); } -function buildTextBackground( - raw: Partial<TextBackground> | undefined, -): TextBackground { - const color = raw?.color ?? DEFAULTS.text.element.background.color; - const enabled = raw?.enabled ?? color !== "transparent"; - return { - enabled, - color, - cornerRadius: raw?.cornerRadius, - paddingX: raw?.paddingX, - paddingY: raw?.paddingY, - offsetX: raw?.offsetX, - offsetY: raw?.offsetY, - }; +function buildDefaultElementParams({ + type, +}: { + type: TimelineElement["type"]; +}): ParamValues { + return buildDefaultParamValues(getBuiltInElementParams({ type })); } export function buildTextElement({ @@ -107,31 +103,21 @@ export function buildTextElement({ startTime, }: { raw: Partial<Omit<TextElement, "type" | "id">>; - startTime: number; + startTime: MediaTime; }): CreateTimelineElement { const t = raw as Partial<TextElement>; return { type: "text", name: t.name ?? DEFAULTS.text.element.name, - content: t.content ?? DEFAULTS.text.element.content, duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, - fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, - fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, - color: t.color ?? DEFAULTS.text.element.color, - background: buildTextBackground(t.background), - textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign, - fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight, - fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle, - textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration, - letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing, - lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight, - transform: t.transform ?? DEFAULTS.text.element.transform, - opacity: t.opacity ?? DEFAULTS.text.element.opacity, - blendMode: t.blendMode ?? DEFAULTS.element.blendMode, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { + ...buildDefaultElementParams({ type: "text" }), + ...(t.params ?? {}), + }, }; } @@ -141,8 +127,8 @@ export function buildEffectElement({ duration, }: { effectType: string; - startTime: number; - duration?: number; + startTime: MediaTime; + duration?: MediaTime; }): CreateEffectElement { const instance = buildDefaultEffectInstance({ effectType }); return { @@ -152,8 +138,8 @@ export function buildEffectElement({ params: instance.params, duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, }; } @@ -166,7 +152,7 @@ export function buildStickerElement({ }: { stickerId: string; name?: string; - startTime: number; + startTime: MediaTime; intrinsicWidth?: number; intrinsicHeight?: number; }): CreateStickerElement { @@ -180,14 +166,9 @@ export function buildStickerElement({ intrinsicHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: buildDefaultElementParams({ type: "sticker" }), }; } @@ -199,7 +180,7 @@ export function buildGraphicElement({ }: { definitionId: string; name?: string; - startTime: number; + startTime: MediaTime; params?: Partial<ParamValues>; }): CreateGraphicElement { const instance = buildDefaultGraphicInstance({ definitionId }); @@ -207,20 +188,36 @@ export function buildGraphicElement({ type: "graphic", name: name ?? capitalizeFirstLetter({ string: instance.definitionId }), definitionId: instance.definitionId, - params: { ...instance.params, ...(params ?? {}) } as ParamValues, + params: mergeParamValues({ + base: { + ...buildDefaultElementParams({ type: "graphic" }), + ...instance.params, + }, + overrides: params, + }), duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, }; } +function mergeParamValues({ + base, + overrides, +}: { + base: ParamValues; + overrides?: Partial<ParamValues>; +}): ParamValues { + const result: ParamValues = { ...base }; + for (const [key, value] of Object.entries(overrides ?? {})) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + function buildVideoElement({ mediaId, name, @@ -229,8 +226,8 @@ function buildVideoElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; }): CreateVideoElement { return { type: "video", @@ -238,19 +235,12 @@ function buildVideoElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, - muted: false, isSourceAudioEnabled: true, hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - volume: DEFAULTS.element.volume, + params: buildDefaultElementParams({ type: "video" }), }; } @@ -262,8 +252,8 @@ function buildImageElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; }): CreateImageElement { return { type: "image", @@ -271,15 +261,10 @@ function buildImageElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, + params: buildDefaultElementParams({ type: "image" }), }; } @@ -292,8 +277,8 @@ function buildUploadAudioElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateUploadAudioElement { const element: CreateUploadAudioElement = { @@ -303,11 +288,10 @@ function buildUploadAudioElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, + params: buildDefaultElementParams({ type: "audio" }), }; if (buffer) { element.buffer = buffer; @@ -326,8 +310,8 @@ export function buildElementFromMedia({ mediaId: string; mediaType: MediaType; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateTimelineElement { switch (mediaType) { @@ -355,8 +339,8 @@ export function buildLibraryAudioElement({ }: { sourceUrl: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateLibraryAudioElement { const element: CreateLibraryAudioElement = { @@ -366,11 +350,10 @@ export function buildLibraryAudioElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, + params: buildDefaultElementParams({ type: "audio" }), }; if (buffer) { element.buffer = buffer; @@ -410,8 +393,8 @@ export function getElementFontFamilies({ const families = new Set<string>(); for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { for (const element of track.elements) { - if (element.type === "text" && element.fontFamily) { - families.add(element.fontFamily); + if (element.type === "text" && typeof element.params.fontFamily === "string") { + families.add(element.params.fontFamily); } if ("masks" in element) { for (const mask of element.masks ?? []) { diff --git a/apps/web/src/timeline/group-move/build-group.ts b/apps/web/src/timeline/group-move/build-group.ts index 00f39d55..3e19e3e3 100644 --- a/apps/web/src/timeline/group-move/build-group.ts +++ b/apps/web/src/timeline/group-move/build-group.ts @@ -2,6 +2,7 @@ import type { ElementRef, SceneTracks } from "@/timeline"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; import type { GroupMember, MoveGroup } from "./types"; import { getTrackPlacementById } from "./track-placement"; +import { subMediaTime } from "@/wasm"; export function buildMoveGroup({ anchorRef, @@ -59,7 +60,10 @@ export function buildMoveGroup({ elementId: element.id, elementType: element.type, duration: element.duration, - timeOffset: element.startTime - anchorElement.startTime, + timeOffset: subMediaTime({ + a: element.startTime, + b: anchorElement.startTime, + }), trackSection: placement.section, sectionIndex: placement.sectionIndex, displayIndex: placement.displayIndex, diff --git a/apps/web/src/timeline/group-move/resolve-move.ts b/apps/web/src/timeline/group-move/resolve-move.ts index 60940315..8307aad0 100644 --- a/apps/web/src/timeline/group-move/resolve-move.ts +++ b/apps/web/src/timeline/group-move/resolve-move.ts @@ -12,6 +12,13 @@ import { getTrackPlacementByDisplayIndex, getTrackPlacementById, } from "./track-placement"; +import { + addMediaTime, + maxMediaTime, + type MediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; type GroupMoveTarget = | { @@ -32,7 +39,7 @@ export function resolveGroupMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; target: GroupMoveTarget; }): GroupMoveResult | null { if (target.kind === "newTracks") { @@ -61,7 +68,7 @@ function resolveExistingTrackMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; anchorTargetTrackId: string; }): GroupMoveResult | null { const anchorTargetPlacement = getTrackPlacementById({ @@ -93,7 +100,10 @@ function resolveExistingTrackMove({ targetTrackId: targetTrackIdsByElementId.get(member.elementId) ?? member.trackId, elementId: member.elementId, - newStartTime: clampedAnchorStartTime + member.timeOffset, + newStartTime: addMediaTime({ + a: clampedAnchorStartTime, + b: member.timeOffset, + }), })); if (!canApplyMovesToExistingTracks({ tracks, moves })) { @@ -119,7 +129,7 @@ function resolveNewTrackMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; anchorInsertIndex: number; newTrackIds: string[]; }): GroupMoveResult | null { @@ -173,7 +183,10 @@ function resolveNewTrackMove({ sourceTrackId: member.trackId, targetTrackId: newTrackIds[memberIndex], elementId: member.elementId, - newStartTime: clampedAnchorStartTime + member.timeOffset, + newStartTime: addMediaTime({ + a: clampedAnchorStartTime, + b: member.timeOffset, + }), })); return { @@ -335,17 +348,26 @@ function clampAnchorStartTime({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; targetTrackIdsByElementId: Map<string, string>; -}): number { - const minimumAnchorStartTime = Math.max( - 0, - ...group.members.map((member) => -member.timeOffset), - ); - let clampedAnchorStartTime = Math.max( - minimumAnchorStartTime, - anchorStartTime, +}): MediaTime { + const minimumAnchorStartTime = group.members.reduce( + (minimumStartTime, member) => + member.timeOffset < ZERO_MEDIA_TIME + ? maxMediaTime({ + a: minimumStartTime, + b: subMediaTime({ + a: ZERO_MEDIA_TIME, + b: member.timeOffset, + }), + }) + : minimumStartTime, + ZERO_MEDIA_TIME, ); + let clampedAnchorStartTime = + anchorStartTime < minimumAnchorStartTime + ? minimumAnchorStartTime + : anchorStartTime; const memberOnMainTrack = group.members.find( (member) => @@ -358,11 +380,13 @@ function clampAnchorStartTime({ const movingElementIds = new Set( group.members.map((member) => member.elementId), ); - const requestedMainStartTime = - clampedAnchorStartTime + memberOnMainTrack.timeOffset; + const requestedMainStartTime = addMediaTime({ + a: clampedAnchorStartTime, + b: memberOnMainTrack.timeOffset, + }); const earliestStationaryMainStartTime = tracks.main.elements .filter((element) => !movingElementIds.has(element.id)) - .reduce<number | null>((earliestStartTime, element) => { + .reduce<MediaTime | null>((earliestStartTime, element) => { if (earliestStartTime == null || element.startTime < earliestStartTime) { return element.startTime; } @@ -373,10 +397,13 @@ function clampAnchorStartTime({ earliestStationaryMainStartTime == null || requestedMainStartTime <= earliestStationaryMainStartTime ) { - clampedAnchorStartTime = Math.max( - minimumAnchorStartTime, - -memberOnMainTrack.timeOffset, - ); + clampedAnchorStartTime = maxMediaTime({ + a: minimumAnchorStartTime, + b: subMediaTime({ + a: ZERO_MEDIA_TIME, + b: memberOnMainTrack.timeOffset, + }), + }); } return clampedAnchorStartTime; @@ -422,7 +449,7 @@ function canApplyMovesToExistingTracks({ const sourceElement = sourceElements.get(move.elementId); return { startTime: move.newStartTime, - duration: sourceElement?.duration ?? 0, + duration: sourceElement?.duration ?? ZERO_MEDIA_TIME, }; }); if (hasOverlappingTimeSpans({ timeSpans })) { diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts index 7f431e6e..7aaf4423 100644 --- a/apps/web/src/timeline/group-move/snap.ts +++ b/apps/web/src/timeline/group-move/snap.ts @@ -9,6 +9,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import type { MoveGroup } from "./types"; +import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm"; export function snapGroupEdges({ group, @@ -18,12 +19,12 @@ export function snapGroupEdges({ zoomLevel, }: { group: MoveGroup; - anchorStartTime: number; + anchorStartTime: MediaTime; tracks: SceneTracks; - playheadTime: number; + playheadTime: MediaTime; zoomLevel: number; }): { - snappedAnchorStartTime: number; + snappedAnchorStartTime: MediaTime; snapPoint: SnapPoint | null; } { const excludeElementIds = new Set( @@ -47,7 +48,10 @@ export function snapGroupEdges({ let snapPoint: SnapPoint | null = null; for (const member of group.members) { - const memberStartTime = anchorStartTime + member.timeOffset; + const memberStartTime = addMediaTime({ + a: anchorStartTime, + b: member.timeOffset, + }); const memberStartSnap = resolveTimelineSnap({ targetTime: memberStartTime, snapPoints, @@ -58,12 +62,18 @@ export function snapGroupEdges({ memberStartSnap.snapDistance < closestSnapDistance ) { closestSnapDistance = memberStartSnap.snapDistance; - snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset; + snappedAnchorStartTime = subMediaTime({ + a: memberStartSnap.snappedTime, + b: member.timeOffset, + }); snapPoint = memberStartSnap.snapPoint; } const memberEndSnap = resolveTimelineSnap({ - targetTime: memberStartTime + member.duration, + targetTime: addMediaTime({ + a: memberStartTime, + b: member.duration, + }), snapPoints, maxSnapDistance, }); @@ -72,8 +82,13 @@ export function snapGroupEdges({ memberEndSnap.snapDistance < closestSnapDistance ) { closestSnapDistance = memberEndSnap.snapDistance; - snappedAnchorStartTime = - memberEndSnap.snappedTime - member.duration - member.timeOffset; + snappedAnchorStartTime = subMediaTime({ + a: subMediaTime({ + a: memberEndSnap.snappedTime, + b: member.duration, + }), + b: member.timeOffset, + }); snapPoint = memberEndSnap.snapPoint; } } diff --git a/apps/web/src/timeline/group-move/types.ts b/apps/web/src/timeline/group-move/types.ts index daf9c6e9..f97b698c 100644 --- a/apps/web/src/timeline/group-move/types.ts +++ b/apps/web/src/timeline/group-move/types.ts @@ -1,11 +1,12 @@ import type { ElementRef, ElementType, TrackType } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export type GroupTrackSection = "overlay" | "main" | "audio"; export interface GroupMember extends ElementRef { elementType: ElementType; - duration: number; - timeOffset: number; + duration: MediaTime; + timeOffset: MediaTime; trackSection: GroupTrackSection; sectionIndex: number; displayIndex: number; @@ -26,7 +27,7 @@ export interface PlannedElementMove { sourceTrackId: string; targetTrackId: string; elementId: string; - newStartTime: number; + newStartTime: MediaTime; } export interface GroupMoveResult { diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts index d22b6891..ed335997 100644 --- a/apps/web/src/timeline/group-resize/compute-resize.ts +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -1,9 +1,20 @@ -import { roundToFrame } from "opencut-wasm"; import { getSourceSpanAtClipTime, getTimelineDurationForSourceSpan, } from "@/retime"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + clampMediaTime, + maxMediaTime, + type MediaTime, + mediaTime, + minMediaTime, + roundFrameTicks, + roundMediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import type { ComputeGroupResizeArgs, GroupResizeMember, @@ -15,63 +26,88 @@ import type { export function computeGroupResize({ members, side, - deltaTicks, + deltaTime, fps, }: ComputeGroupResizeArgs): GroupResizeResult { - const minDuration = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); - const minimumDeltaTicks = Math.max( - ...members.map((member) => - getMinimumAllowedDeltaTicks({ - member, - side, - minDuration, - }), - ), - ); - const maximumDeltaTicks = Math.min( - ...members.map((member) => - getMaximumAllowedDeltaTicks({ + if (members.length === 0) { + return { deltaTime: ZERO_MEDIA_TIME, updates: [] }; + } + + const minDuration = mediaTime({ + ticks: Math.round((TICKS_PER_SECOND * fps.denominator) / fps.numerator), + }); + let minimumDeltaTime = getMinimumAllowedDeltaTime({ + member: members[0], + side, + minDuration, + }); + let maximumDeltaTime = getMaximumAllowedDeltaTime({ + member: members[0], + side, + minDuration, + }); + + for (const member of members.slice(1)) { + minimumDeltaTime = maxMediaTime({ + a: minimumDeltaTime, + b: getMinimumAllowedDeltaTime({ member, side, minDuration, }), - ), - ); - const clampedDeltaTicks = - minimumDeltaTicks > maximumDeltaTicks - ? minimumDeltaTicks - : Math.min(maximumDeltaTicks, Math.max(minimumDeltaTicks, deltaTicks)); + }); + const memberMaximum = getMaximumAllowedDeltaTime({ + member, + side, + minDuration, + }); + if (memberMaximum !== null) { + maximumDeltaTime = + maximumDeltaTime === null + ? memberMaximum + : minMediaTime({ a: maximumDeltaTime, b: memberMaximum }); + } + } + + const clampedDeltaTime = + maximumDeltaTime === null + ? maxMediaTime({ a: minimumDeltaTime, b: deltaTime }) + : clampMediaTime({ + time: deltaTime, + min: minimumDeltaTime, + max: maximumDeltaTime, + }); // Snap the drag delta to a frame exactly once, then derive every patch // field from that single snapped value. This keeps the invariant // `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same // delta is added on one side of the element and removed from the other, - // so the rounding cancels by construction. Rounding each field - // independently would break this — the individual rounds don't compose - // when `sourceDuration` isn't frame-aligned. - const snappedDeltaTicks = - roundToFrame({ time: clampedDeltaTicks, rate: fps }) ?? clampedDeltaTicks; + // so the rounding cancels by construction. Per-field rounding (the old + // approach) couldn't preserve this because the individual rounds don't + // compose when `sourceDuration` isn't frame-aligned. + const snappedDeltaTime = mediaTime({ + ticks: roundFrameTicks({ ticks: clampedDeltaTime, fps }), + }); // Re-clamp after rounding. Bounds derived from other elements are // frame-aligned, so this is normally a no-op; at the source-extent limit // the bound may not be frame-aligned, and honouring the bound takes // precedence over frame alignment (you can't extend past real content). - const finalDeltaTicks = - minimumDeltaTicks > maximumDeltaTicks - ? minimumDeltaTicks - : Math.min( - maximumDeltaTicks, - Math.max(minimumDeltaTicks, snappedDeltaTicks), - ); + const finalDeltaTime = + maximumDeltaTime === null + ? maxMediaTime({ a: minimumDeltaTime, b: snappedDeltaTime }) + : clampMediaTime({ + time: snappedDeltaTime, + min: minimumDeltaTime, + max: maximumDeltaTime, + }); return { - deltaTicks: Object.is(finalDeltaTicks, -0) ? 0 : finalDeltaTicks, + deltaTime: Object.is(finalDeltaTime, -0) ? ZERO_MEDIA_TIME : finalDeltaTime, updates: members.map((member) => buildResizeUpdate({ member, side, - deltaTicks: finalDeltaTicks, + deltaTime: finalDeltaTime, }), ), }; @@ -80,27 +116,30 @@ export function computeGroupResize({ function buildResizeUpdate({ member, side, - deltaTicks, + deltaTime, }: { member: GroupResizeMember; side: ResizeSide; - deltaTicks: number; + deltaTime: MediaTime; }): GroupResizeUpdate { const sourceDelta = getSourceDeltaForClipDelta({ member, - clipDelta: deltaTicks, + clipDelta: deltaTime, }); if (side === "left") { return { trackId: member.trackId, elementId: member.elementId, - patch: { - trimStart: Math.max(0, member.trimStart + sourceDelta), - trimEnd: member.trimEnd, - startTime: member.startTime + deltaTicks, - duration: member.duration - deltaTicks, - }, + patch: { + trimStart: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: member.trimStart, b: sourceDelta }), + }), + trimEnd: member.trimEnd, + startTime: addMediaTime({ a: member.startTime, b: deltaTime }), + duration: subMediaTime({ a: member.duration, b: deltaTime }), + }, }; } @@ -109,72 +148,95 @@ function buildResizeUpdate({ elementId: member.elementId, patch: { trimStart: member.trimStart, - trimEnd: Math.max(0, member.trimEnd - sourceDelta), + trimEnd: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ a: member.trimEnd, b: sourceDelta }), + }), startTime: member.startTime, - duration: member.duration + deltaTicks, + duration: addMediaTime({ a: member.duration, b: deltaTime }), }, }; } -function getMinimumAllowedDeltaTicks({ +function getMinimumAllowedDeltaTime({ member, side, minDuration, }: { member: GroupResizeMember; side: ResizeSide; - minDuration: number; -}): number { + minDuration: MediaTime; +}): MediaTime { if (side === "right") { - return minDuration - member.duration; + return subMediaTime({ a: minDuration, b: member.duration }); } - const leftNeighborFloor = Number.isFinite(member.leftNeighborBound) - ? member.leftNeighborBound - member.startTime - : -member.startTime; + const leftNeighborFloor = + member.leftNeighborBound !== null + ? subMediaTime({ a: member.leftNeighborBound, b: member.startTime }) + : subMediaTime({ a: ZERO_MEDIA_TIME, b: member.startTime }); if (member.sourceDuration == null) { return leftNeighborFloor; } - const maximumSourceExtension = - getDurationForVisibleSourceSpan({ + const maximumSourceExtension = subMediaTime({ + a: getDurationForVisibleSourceSpan({ member, - sourceSpan: - getVisibleSourceSpanForDuration({ + sourceSpan: addMediaTime({ + a: getVisibleSourceSpanForDuration({ member, duration: member.duration, - }) + member.trimStart, - }) - member.duration; - return Math.max(leftNeighborFloor, -maximumSourceExtension); + }), + b: member.trimStart, + }), + }), + b: member.duration, + }); + return maxMediaTime({ + a: leftNeighborFloor, + b: subMediaTime({ a: ZERO_MEDIA_TIME, b: maximumSourceExtension }), + }); } -function getMaximumAllowedDeltaTicks({ +function getMaximumAllowedDeltaTime({ member, side, minDuration, }: { member: GroupResizeMember; side: ResizeSide; - minDuration: number; -}): number { + minDuration: MediaTime; +}): MediaTime | null { if (side === "left") { - return member.duration - minDuration; + return subMediaTime({ a: member.duration, b: minDuration }); } - const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound) - ? member.rightNeighborBound - (member.startTime + member.duration) - : Infinity; + const rightNeighborCeiling = + member.rightNeighborBound === null + ? null + : subMediaTime({ + a: member.rightNeighborBound, + b: addMediaTime({ a: member.startTime, b: member.duration }), + }); if (member.sourceDuration == null) { return rightNeighborCeiling; } - const maximumVisibleSourceSpan = - getSourceDuration({ member }) - member.trimStart; + const maximumVisibleSourceSpan = subMediaTime({ + a: getSourceDuration({ member }), + b: member.trimStart, + }); const maximumDuration = getDurationForVisibleSourceSpan({ member, sourceSpan: maximumVisibleSourceSpan, }); - return Math.min(rightNeighborCeiling, maximumDuration - member.duration); + const sourceDurationCeiling = subMediaTime({ + a: maximumDuration, + b: member.duration, + }); + return rightNeighborCeiling === null + ? sourceDurationCeiling + : minMediaTime({ a: rightNeighborCeiling, b: sourceDurationCeiling }); } function getSourceDeltaForClipDelta({ @@ -182,21 +244,23 @@ function getSourceDeltaForClipDelta({ clipDelta, }: { member: GroupResizeMember; - clipDelta: number; -}): number { + clipDelta: MediaTime; +}): MediaTime { if (!member.retime) { return clipDelta; } - return clipDelta >= 0 - ? getSourceSpanAtClipTime({ - clipTime: clipDelta, - retime: member.retime, - }) - : -getSourceSpanAtClipTime({ - clipTime: Math.abs(clipDelta), - retime: member.retime, - }); + const sourceDelta = + clipDelta >= 0 + ? getSourceSpanAtClipTime({ + clipTime: clipDelta, + retime: member.retime, + }) + : -getSourceSpanAtClipTime({ + clipTime: Math.abs(clipDelta), + retime: member.retime, + }); + return roundMediaTime({ time: sourceDelta }); } function getVisibleSourceSpanForDuration({ @@ -204,15 +268,17 @@ function getVisibleSourceSpanForDuration({ duration, }: { member: GroupResizeMember; - duration: number; -}): number { + duration: MediaTime; +}): MediaTime { if (!member.retime) { return duration; } - return getSourceSpanAtClipTime({ - clipTime: duration, - retime: member.retime, + return roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: duration, + retime: member.retime, + }), }); } @@ -221,29 +287,33 @@ function getDurationForVisibleSourceSpan({ sourceSpan, }: { member: GroupResizeMember; - sourceSpan: number; -}): number { + sourceSpan: MediaTime; +}): MediaTime { if (!member.retime) { return sourceSpan; } - return getTimelineDurationForSourceSpan({ - sourceSpan, - retime: member.retime, + return roundMediaTime({ + time: getTimelineDurationForSourceSpan({ + sourceSpan, + retime: member.retime, + }), }); } -function getSourceDuration({ member }: { member: GroupResizeMember }): number { - if (typeof member.sourceDuration === "number") { +function getSourceDuration({ member }: { member: GroupResizeMember }): MediaTime { + if (member.sourceDuration != null) { return member.sourceDuration; } - return ( - member.trimStart + - getVisibleSourceSpanForDuration({ + return addMediaTime({ + a: addMediaTime({ + a: member.trimStart, + b: getVisibleSourceSpanForDuration({ member, duration: member.duration, - }) + - member.trimEnd - ); + }), + }), + b: member.trimEnd, + }); } diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts index 511358ba..5c0f77f6 100644 --- a/apps/web/src/timeline/group-resize/types.ts +++ b/apps/web/src/timeline/group-resize/types.ts @@ -1,36 +1,37 @@ import type { FrameRate } from "opencut-wasm"; import type { ElementRef, RetimeConfig } from "@/timeline/types"; +import type { MediaTime } from "@/wasm"; export type ResizeSide = "left" | "right"; export interface GroupResizeMember extends ElementRef { - startTime: number; - duration: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; + startTime: MediaTime; + duration: MediaTime; + trimStart: MediaTime; + trimEnd: MediaTime; + sourceDuration?: MediaTime; retime?: RetimeConfig; - leftNeighborBound: number; - rightNeighborBound: number; + leftNeighborBound: MediaTime | null; + rightNeighborBound: MediaTime | null; } export interface GroupResizeUpdate extends ElementRef { patch: { - trimStart: number; - trimEnd: number; - startTime: number; - duration: number; + trimStart: MediaTime; + trimEnd: MediaTime; + startTime: MediaTime; + duration: MediaTime; }; } export interface GroupResizeResult { - deltaTicks: number; + deltaTime: MediaTime; updates: GroupResizeUpdate[]; } export interface ComputeGroupResizeArgs { members: GroupResizeMember[]; side: ResizeSide; - deltaTicks: number; + deltaTime: MediaTime; fps: FrameRate; } diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts index 26a21f85..220f3fec 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef, type RefObject } from "react"; +import { useEffect, useReducer, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { registerCanceller } from "@/editor/cancel-interaction"; @@ -63,17 +64,10 @@ export function useElementInteraction({ onChange: onSnapPointChange, }, }; - - const depsRef = useRef<ElementInteractionDeps>(deps); - depsRef.current = deps; - - const controllerRef = useRef<ElementInteractionController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ElementInteractionController({ - depsRef: depsRef as ElementInteractionDepsRef, - }); - } - const controller = controllerRef.current; + const depsRef = useCommittedRef(deps) as ElementInteractionDepsRef; + const [controller] = useState( + () => new ElementInteractionController({ depsRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts index 5a1ff1f0..141bba8f 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useKeyframeSelection } from "./use-keyframe-selection"; import { registerCanceller } from "@/editor/cancel-interaction"; import { @@ -8,6 +9,7 @@ import { type KeyframeDragState, } from "@/timeline/controllers/keyframe-drag-controller"; import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export type { KeyframeDragState }; @@ -18,7 +20,7 @@ export function useKeyframeDrag({ }: { zoomLevel: number; element: TimelineElement; - displayedStartTime: number; + displayedStartTime: MediaTime; }) { const editor = useEditor(); const { @@ -40,18 +42,13 @@ export function useKeyframeDrag({ toggleKeyframeSelection, selectKeyframeRange, executeCommand: (command) => editor.command.execute({ command }), - seek: (args) => editor.playback.seek(args), + seek: ({ time }) => editor.playback.seek({ time }), getTotalDuration: () => editor.timeline.getTotalDuration(), }; - - const configRef = useRef<KeyframeDragConfig>(config); - configRef.current = config; - - const controllerRef = useRef<KeyframeDragController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new KeyframeDragController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState( + () => new KeyframeDragController({ configRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts index 2081b939..ac6d91eb 100644 --- a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts +++ b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react";
+import { useContainerSize } from "@/hooks/use-container-size";
import { timelineTimeToSnappedPixels } from "@/timeline";
import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/timeline/components/layout";
+import { useScrollPosition } from "./use-scroll-position";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
@@ -20,24 +21,9 @@ export function useSnapIndicatorPosition({ timelineRef,
tracksScrollRef,
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
- const [scrollLeft, setScrollLeft] = useState(0);
-
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
-
- if (!tracksViewport) return;
-
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
-
- setScrollLeft(tracksViewport.scrollLeft);
-
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
-
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const { height: timelineHeight } = useContainerSize({ containerRef: timelineRef });
+ const { scrollLeft } = useScrollPosition({ scrollRef: tracksScrollRef });
+ const timelineContainerHeight = timelineHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
const timelinePosition = timelineTimeToSnappedPixels({
diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts index 4ec2b80b..c405819e 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef, type RefObject } from "react"; +import { useEffect, useReducer, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { DragDropController, type DragDropConfig, @@ -37,15 +38,8 @@ export function useTimelineDragDrop({ insertElement: (args) => editor.timeline.insertElement(args), addClipEffect: (args) => editor.timeline.addClipEffect(args), }; - - const configRef = useRef<DragDropConfig>(config); - configRef.current = config; - - const controllerRef = useRef<DragDropController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new DragDropController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new DragDropController({ configRef })); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 12bfe76a..9f10c4a5 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,5 +1,6 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; import { timelineTimeToPixels } from "@/timeline"; @@ -7,6 +8,7 @@ import { PlayheadController, type PlayheadConfig, } from "@/timeline/controllers/playhead-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelinePlayheadProps { zoomLevel: number; @@ -44,17 +46,17 @@ export function useTimelinePlayhead({ seek: (time) => editor.playback.seek({ time }), setScrubbing: (scrubbing) => editor.playback.setScrubbing({ isScrubbing: scrubbing }), - setTimelineViewState: (viewState) => - editor.project.setTimelineViewState({ viewState }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), }; - const configRef = useRef(config); - configRef.current = config; - - const ctrlRef = useRef<PlayheadController | null>(null); - if (!ctrlRef.current) { - ctrlRef.current = new PlayheadController({ configRef }); - } - const ctrl = ctrlRef.current; + const configRef = useCommittedRef(config); + const [ctrl] = useState(() => new PlayheadController({ configRef })); // Scroll → keep playhead position in sync with scroll offset. useEffect(() => { @@ -68,7 +70,7 @@ export function useTimelinePlayhead({ // Playback events → update playhead position and auto-scroll during playback. useEffect(() => { - const handler = (time: number) => ctrl.handlePlaybackUpdate(time); + const handler = (time: MediaTime) => ctrl.handlePlaybackUpdate(time); ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); const unsubscribeUpdate = editor.playback.onUpdate(handler); const unsubscribeSeek = editor.playback.onSeek(handler); diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index 46363fb6..6bd0c606 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useTimelineStore } from "@/timeline/timeline-store"; @@ -10,6 +11,7 @@ import { } from "@/timeline/controllers/resize-controller"; import type { ResizeSide } from "@/timeline/group-resize"; import type { SnapPoint } from "@/timeline/snapping"; +import type { TimelineElement } from "@/timeline"; export type { ResizeSide }; @@ -41,7 +43,7 @@ export function useTimelineResize({ updates: updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, - updates: patch, + updates: patch as Partial<TimelineElement>, })), }), commitElements: (updates) => @@ -49,20 +51,13 @@ export function useTimelineResize({ updates: updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, - patch, + patch: patch as Partial<TimelineElement>, })), }), onSnapPointChange, }; - - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<ResizeController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ResizeController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new ResizeController({ configRef })); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index 56f68ed6..ea9b44a7 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -1,9 +1,11 @@ -import { useEffect, useRef, type RefObject } from "react"; +import { useEffect, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { SeekController, type SeekConfig, } from "@/timeline/controllers/seek-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelineSeekProps { playheadRef: RefObject<HTMLDivElement | null>; @@ -11,10 +13,10 @@ interface UseTimelineSeekProps { rulerScrollRef: RefObject<HTMLDivElement | null>; tracksScrollRef: RefObject<HTMLDivElement | null>; zoomLevel: number; - duration: number; + duration: MediaTime; isSelecting: boolean; clearSelectedElements: () => void; - seek: (time: number) => void; + seek: (time: MediaTime) => void; } export function useTimelineSeek({ @@ -40,18 +42,17 @@ export function useTimelineSeek({ getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, clearSelectedElements, seek, - setTimelineViewState: (viewState) => - editor.project.setTimelineViewState({ viewState }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), }; - - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<SeekController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new SeekController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new SeekController({ configRef })); useEffect(() => () => controller.destroy(), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index a8825a37..60768963 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -4,21 +4,23 @@ import { useEffect, useLayoutEffect, useReducer, - useRef, + useState, } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { TIMELINE_ZOOM_MIN } from "@/timeline/scale"; import { ZoomController, type ZoomConfig, } from "@/timeline/controllers/zoom-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelineZoomProps { containerRef: RefObject<HTMLDivElement | null>; minZoom?: number; initialZoom?: number; initialScrollLeft?: number; - initialPlayheadTime?: number; + initialPlayheadTime?: MediaTime; tracksScrollRef: RefObject<HTMLDivElement | null>; rulerScrollRef: RefObject<HTMLDivElement | null>; } @@ -47,24 +49,26 @@ export function useTimelineZoom({ getRulerScrollEl: () => rulerScrollRef.current, getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), seek: (time) => editor.playback.seek({ time }), - setTimelineViewState: (viewState) => - editor.project.setTimelineViewState({ viewState }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), }; - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<ZoomController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ZoomController({ configRef, initialZoom }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState( + () => new ZoomController({ configRef, initialZoom }), + ); const zoomLevel = controller.zoomLevel; const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - controller.reconcileInitialAndMinZoom(minZoom, initialZoom); + controller.reconcileInitialAndMinZoom({ minZoom, initialZoom }); }, [controller, minZoom, initialZoom]); useLayoutEffect(() => { diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts index 5ee079b1..21944561 100644 --- a/apps/web/src/timeline/index.ts +++ b/apps/web/src/timeline/index.ts @@ -1,29 +1,33 @@ -import type { SceneTracks } from "./types";
-
-export * from "./types";
-export * from "./drag";
-export * from "./track-capabilities";
-export * from "./track-element-update";
-export * from "./element-utils";
-export * from "./audio-separation";
-export * from "./zoom-utils";
-export * from "./ruler-utils";
-export * from "./pixel-utils";
-
-export function calculateTotalDuration({
- tracks,
-}: {
- tracks: SceneTracks;
-}): number {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- if (orderedTracks.length === 0) return 0;
-
- const trackEndTimes = orderedTracks.map((track) =>
- track.elements.reduce((maxEnd, element) => {
- const elementEnd = element.startTime + element.duration;
- return Math.max(maxEnd, elementEnd);
- }, 0),
- );
-
- return Math.max(...trackEndTimes, 0);
-}
+import { addMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +import type { SceneTracks } from "./types"; + +export * from "./types"; +export * from "./drag"; +export * from "./track-capabilities"; +export * from "./track-element-update"; +export * from "./element-utils"; +export * from "./audio-separation"; +export * from "./zoom-utils"; +export * from "./ruler-utils"; +export * from "./pixel-utils"; + +export function calculateTotalDuration({ + tracks, +}: { + tracks: SceneTracks; +}): MediaTime { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + if (orderedTracks.length === 0) return ZERO_MEDIA_TIME; + + let maxEnd: MediaTime = ZERO_MEDIA_TIME; + for (const track of orderedTracks) { + for (const element of track.elements) { + const elementEnd = addMediaTime({ + a: element.startTime, + b: element.duration, + }); + if (elementEnd > maxEnd) maxEnd = elementEnd; + } + } + return maxEnd; +} diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts index ebbfe578..202c9eb8 100644 --- a/apps/web/src/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts @@ -14,6 +14,7 @@ import type { } from "@/timeline"; import type { Transform } from "@/rendering"; import { resolveTrackPlacement } from "@/timeline/placement"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; function buildTransform(): Transform { return { @@ -67,11 +68,11 @@ function buildElement({ id, type: "audio", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, - volume: 1, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { volume: 1, muted: false }, sourceType: "upload", mediaId: `media-${id}`, } satisfies AudioElement; @@ -80,51 +81,66 @@ function buildElement({ id, type: "graphic", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, definitionId: `graphic-${id}`, - params: {}, - transform: buildTransform(), - opacity: 1, + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, } satisfies GraphicElement; case "text": return { id, type: "text", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, - content: id, - fontSize: 32, - fontFamily: "sans-serif", - color: "#ffffff", - background: { - enabled: false, - color: "#000000", + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { + content: id, + fontSize: 32, + fontFamily: "sans-serif", + color: "#ffffff", + "background.enabled": false, + "background.color": "#000000", + textAlign: "left", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, }, - textAlign: "left", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - transform: buildTransform(), - opacity: 1, } satisfies TextElement; case "video": return { id, type: "video", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, mediaId: `media-${id}`, - transform: buildTransform(), - opacity: 1, + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, } satisfies VideoElement; } @@ -224,7 +240,11 @@ function buildTimeSpan({ duration: number; excludeElementId?: string; }) { - return { startTime, duration, excludeElementId }; + return { + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + excludeElementId, + }; } function buildSceneTracks({ diff --git a/apps/web/src/timeline/placement/main-track.ts b/apps/web/src/timeline/placement/main-track.ts index ab2ab7bd..36632226 100644 --- a/apps/web/src/timeline/placement/main-track.ts +++ b/apps/web/src/timeline/placement/main-track.ts @@ -1,4 +1,5 @@ import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export const MAIN_TRACK_NAME = "Main Track"; @@ -31,9 +32,9 @@ export function enforceMainTrackStart({ }: { tracks: SceneTracks; targetTrackId: string; - requestedStartTime: number; + requestedStartTime: MediaTime; excludeElementId?: string; -}): number { +}): MediaTime { if (tracks.main.id !== targetTrackId) { return requestedStartTime; } @@ -43,11 +44,11 @@ export function enforceMainTrackStart({ excludeElementId, }); if (!earliestElement) { - return 0; + return ZERO_MEDIA_TIME; } if (requestedStartTime <= earliestElement.startTime) { - return 0; + return ZERO_MEDIA_TIME; } return requestedStartTime; diff --git a/apps/web/src/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts index 26275392..680cb0bb 100644 --- a/apps/web/src/timeline/placement/resolve.ts +++ b/apps/web/src/timeline/placement/resolve.ts @@ -13,6 +13,7 @@ import type { PlacementSubject, PlacementTimeSpan, } from "./types"; +import { ZERO_MEDIA_TIME } from "@/wasm"; type ResolveTrackPlacementParams = PlacementSubject & { tracks: SceneTracks; @@ -32,7 +33,7 @@ function buildExistingTrackResult({ timeSpans: PlacementTimeSpan[]; }): PlacementResult { const firstSpan = timeSpans[0]; - const requestedStartTime = firstSpan?.startTime ?? 0; + const requestedStartTime = firstSpan?.startTime ?? ZERO_MEDIA_TIME; const adjustedStartTime = enforceMainTrackStart({ tracks, targetTrackId: track.id, diff --git a/apps/web/src/timeline/placement/types.ts b/apps/web/src/timeline/placement/types.ts index 6847ab36..748f34d7 100644 --- a/apps/web/src/timeline/placement/types.ts +++ b/apps/web/src/timeline/placement/types.ts @@ -1,8 +1,9 @@ import type { ElementType, TrackType } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface PlacementTimeSpan { - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; excludeElementId?: string; } @@ -29,7 +30,7 @@ export type PlacementResult = trackId: string; trackIndex: number; trackType: TrackType; - adjustedStartTime?: number; + adjustedStartTime?: MediaTime; } | { kind: "newTrack"; diff --git a/apps/web/src/timeline/playhead-snap-source.ts b/apps/web/src/timeline/playhead-snap-source.ts index 88e21eba..0b41e17b 100644 --- a/apps/web/src/timeline/playhead-snap-source.ts +++ b/apps/web/src/timeline/playhead-snap-source.ts @@ -1,9 +1,10 @@ import type { SnapPoint } from "@/timeline/snapping"; +import type { MediaTime } from "@/wasm"; export function getPlayheadSnapPoints({ playheadTime, }: { - playheadTime: number; + playheadTime: MediaTime; }): SnapPoint[] { return [{ time: playheadTime, type: "playhead" }]; } diff --git a/apps/web/src/timeline/scenes.ts b/apps/web/src/timeline/scenes.ts index d48c943c..8aa1b6b5 100644 --- a/apps/web/src/timeline/scenes.ts +++ b/apps/web/src/timeline/scenes.ts @@ -2,6 +2,7 @@ import type { TScene } from "@/timeline"; import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/timeline";
import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null;
@@ -89,10 +90,10 @@ export function getProjectDurationFromScenes({ scenes,
}: {
scenes: TScene[];
-}): number {
+}): MediaTime {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
if (!mainScene?.tracks) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
return calculateTotalDuration({ tracks: mainScene.tracks });
diff --git a/apps/web/src/timeline/snapping/resolve.ts b/apps/web/src/timeline/snapping/resolve.ts index 2f00d081..dcbc670d 100644 --- a/apps/web/src/timeline/snapping/resolve.ts +++ b/apps/web/src/timeline/snapping/resolve.ts @@ -1,11 +1,12 @@ import type { SnapPoint, SnapResult } from "./types"; +import type { MediaTime } from "@/wasm"; export function resolveTimelineSnap({ targetTime, snapPoints, maxSnapDistance, }: { - targetTime: number; + targetTime: MediaTime; snapPoints: SnapPoint[]; maxSnapDistance: number; }): SnapResult { diff --git a/apps/web/src/timeline/snapping/types.ts b/apps/web/src/timeline/snapping/types.ts index 2a6d0903..7bffd84f 100644 --- a/apps/web/src/timeline/snapping/types.ts +++ b/apps/web/src/timeline/snapping/types.ts @@ -1,3 +1,5 @@ +import type { MediaTime } from "@/wasm"; + export type SnapPointType = | "element-start" | "element-end" @@ -6,14 +8,14 @@ export type SnapPointType = | "keyframe"; export interface SnapPoint { - time: number; + time: MediaTime; type: SnapPointType; elementId?: string; trackId?: string; } export interface SnapResult { - snappedTime: number; + snappedTime: MediaTime; snapPoint: SnapPoint | null; snapDistance: number; } diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index 338da9d9..5b3e4d29 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -2,7 +2,7 @@ import type { ElementAnimations } from "@/animation/types"; import type { Effect } from "@/effects/types"; import type { Mask } from "@/masks/types"; import type { ParamValues } from "@/params"; -import type { BlendMode, Transform } from "@/rendering"; +import type { MediaTime } from "@/wasm"; export type ElementRef = { trackId: string; @@ -10,10 +10,10 @@ export type ElementRef = { }; export interface Bookmark { - time: number; + time: MediaTime; note?: string; color?: string; - duration?: number; + duration?: MediaTime; } export interface TScene { @@ -86,8 +86,6 @@ export interface RetimeConfig { interface BaseAudioElement extends BaseTimelineElement { type: "audio"; - volume: number; - muted?: boolean; buffer?: AudioBuffer; retime?: RetimeConfig; } @@ -107,25 +105,21 @@ export type AudioElement = UploadAudioElement | LibraryAudioElement; interface BaseTimelineElement { id: string; name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; + duration: MediaTime; + startTime: MediaTime; + trimStart: MediaTime; + trimEnd: MediaTime; + sourceDuration?: MediaTime; animations?: ElementAnimations; + params: ParamValues; } export interface VideoElement extends BaseTimelineElement { type: "video"; mediaId: string; - volume?: number; - muted?: boolean; isSourceAudioEnabled?: boolean; hidden?: boolean; retime?: RetimeConfig; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; masks?: Mask[]; } @@ -134,40 +128,13 @@ export interface ImageElement extends BaseTimelineElement { type: "image"; mediaId: string; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; masks?: Mask[]; } -export interface TextBackground { - enabled: boolean; - color: string; - cornerRadius?: number; - paddingX?: number; - paddingY?: number; - offsetX?: number; - offsetY?: number; -} - export interface TextElement extends BaseTimelineElement { type: "text"; - content: string; - fontSize: number; - fontFamily: string; - color: string; - background: TextBackground; - textAlign: "left" | "center" | "right"; - fontWeight: "normal" | "bold"; - fontStyle: "normal" | "italic"; - textDecoration: "none" | "underline" | "line-through"; - letterSpacing?: number; - lineHeight?: number; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; } @@ -178,20 +145,13 @@ export interface StickerElement extends BaseTimelineElement { intrinsicWidth?: number; intrinsicHeight?: number; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; } export interface GraphicElement extends BaseTimelineElement { type: "graphic"; definitionId: string; - params: ParamValues; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; masks?: Mask[]; } @@ -199,13 +159,9 @@ export interface GraphicElement extends BaseTimelineElement { export interface EffectElement extends BaseTimelineElement { type: "effect"; effectType: string; - params: ParamValues; } -export type ElementUpdatePatch = - | { transform: Transform } - | { opacity: number } - | { volume: number }; +export type ElementUpdatePatch = { params?: Partial<ParamValues> }; export type TimelineElement = | AudioElement @@ -269,18 +225,32 @@ export type CreateTimelineElement = | CreateGraphicElement | CreateEffectElement; +export interface ElementDragState { + isDragging: boolean; + elementId: string | null; + dragElementIds: string[]; + dragTimeOffsets: Record<string, MediaTime>; + trackId: string | null; + startMouseX: number; + startMouseY: number; + startElementTime: MediaTime; + clickOffsetTime: MediaTime; + currentTime: MediaTime; + currentMouseY: number; +} + export type ElementDragView = | { readonly kind: "idle" } | { readonly kind: "dragging"; readonly anchorElementId: string; readonly trackId: string; - readonly memberTimeOffsets: ReadonlyMap<string, number>; + readonly memberTimeOffsets: ReadonlyMap<string, MediaTime>; readonly startMouseX: number; readonly startMouseY: number; - readonly startElementTime: number; - readonly clickOffsetTime: number; - readonly currentTime: number; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; + readonly currentTime: MediaTime; readonly currentMouseX: number; readonly currentMouseY: number; readonly dropTarget: DropTarget | null; @@ -290,7 +260,7 @@ export interface DropTarget { trackIndex: number; isNewTrack: boolean; insertPosition: "above" | "below" | null; - xPosition: number; + xPosition: MediaTime; targetElement: { elementId: string; trackId: string } | null; } @@ -299,13 +269,13 @@ export interface ComputeDropTargetParams { mouseX: number; mouseY: number; tracks: SceneTracks; - playheadTime: number; + playheadTime: MediaTime; isExternalDrop: boolean; - elementDuration: number; + elementDuration: MediaTime; pixelsPerSecond: number; zoomLevel: number; verticalDragDirection?: "up" | "down" | null; - startTimeOverride?: number; + startTimeOverride?: MediaTime; excludeElementId?: string; targetElementTypes?: string[]; } diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts index beacf897..590a8f34 100644 --- a/apps/web/src/timeline/update-pipeline.ts +++ b/apps/web/src/timeline/update-pipeline.ts @@ -6,6 +6,7 @@ import { } from "@/retime"; import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline"; import { isRetimableElement } from "@/timeline"; +import { ZERO_MEDIA_TIME, roundMediaTime } from "@/wasm"; type ElementUpdateField = keyof TimelineElement | string; @@ -61,9 +62,11 @@ const deriveRules: ElementUpdateRule[] = [ 0, sourceDuration - element.trimStart - element.trimEnd, ); - const nextDuration = getTimelineDurationForSourceSpan({ - sourceSpan: visibleSourceSpan, - retime: nextRetime, + const nextDuration = roundMediaTime({ + time: getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }), }); return { @@ -94,7 +97,10 @@ const enforceRules: ElementUpdateRule[] = [ { triggers: ["startTime"], apply: ({ element, context }) => { - const requestedStartTime = Math.max(0, element.startTime); + const requestedStartTime = + element.startTime < ZERO_MEDIA_TIME + ? ZERO_MEDIA_TIME + : element.startTime; if (context.trackId !== context.tracks.main.id) { return { element: { @@ -118,7 +124,7 @@ const enforceRules: ElementUpdateRule[] = [ ...element, startTime: !earliestElement || requestedStartTime <= earliestElement.startTime - ? 0 + ? ZERO_MEDIA_TIME : requestedStartTime, }, }; @@ -135,7 +141,14 @@ export function applyElementUpdate({ patch: Partial<TimelineElement>; context: ElementUpdateContext; }): TimelineElement { - let nextElement = { ...element, ...patch } as TimelineElement; + let nextElement = { + ...element, + ...patch, + params: { + ...element.params, + ...(patch.params ?? {}), + }, + } as TimelineElement; const changedFields = new Set( Object.keys(patch) as ElementUpdateField[], ); diff --git a/apps/web/src/utils/geometry.ts b/apps/web/src/utils/geometry.ts index 23fc4f46..b5c50584 100644 --- a/apps/web/src/utils/geometry.ts +++ b/apps/web/src/utils/geometry.ts @@ -5,8 +5,14 @@ export function dimensionToAspectRatio({ width: number; height: number; }): string { - const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b)); - const divisor = gcd(width, height); + const gcd = ({ + a, + b, + }: { + a: number; + b: number; + }): number => (b === 0 ? a : gcd({ a: b, b: a % b })); + const divisor = gcd({ a: width, b: height }); const aspectWidth = width / divisor; const aspectHeight = height / divisor; return `${aspectWidth}:${aspectHeight}`; diff --git a/apps/web/src/wasm/index.ts b/apps/web/src/wasm/index.ts index 60031a0e..c979b620 100644 --- a/apps/web/src/wasm/index.ts +++ b/apps/web/src/wasm/index.ts @@ -1 +1 @@ -export * from "./ticks"; +export * from "./media-time"; diff --git a/apps/web/src/wasm/media-time.ts b/apps/web/src/wasm/media-time.ts new file mode 100644 index 00000000..0ec23cf3 --- /dev/null +++ b/apps/web/src/wasm/media-time.ts @@ -0,0 +1,232 @@ +import { + lastFrameTime as _lastFrameTime, + parseTimecode as _parseTimecode, + roundToFrame as _roundToFrame, + snappedSeekTime as _snappedSeekTime, + TICKS_PER_SECOND as _TICKS_PER_SECOND, + mediaTimeFromSeconds as _mediaTimeFromSeconds, + mediaTimeToSeconds as _mediaTimeToSeconds, + type FrameRate, + type TimeCodeFormat, +} from "opencut-wasm"; + +/** + * Integer-tick time. Mirrors `MediaTime(i64)` in `rust/crates/time/src/media_time.rs`. + * + * `opencut-wasm` exposes `MediaTime` as a bare `number` alias because tsify + * collapses tuple structs. The brand here is the TS-side discipline that + * recovers the invariant: a `MediaTime` is an integer count of ticks, and the + * only legal way to construct one from a fractional `number` is `roundMediaTime` + * (or `mediaTimeFromSeconds`, which rounds inside the wasm boundary). + * + * Reading is free — `MediaTime` is assignable to `number`. Writing is gated — + * a bare `number` is not assignable to `MediaTime`. + */ +export type MediaTime = number & { readonly __mediaTime: unique symbol }; + +export const TICKS_PER_SECOND = _TICKS_PER_SECOND(); + +function isMediaTime(value: number): value is MediaTime { + return Number.isInteger(value); +} + +function requireMediaTime({ + value, + context, +}: { + value: number; + context: string; +}): MediaTime { + if (!isMediaTime(value)) { + throw new Error(`${context}: expected an integer tick count, got ${value}`); + } + return value; +} + +export const ZERO_MEDIA_TIME = requireMediaTime({ + value: 0, + context: "ZERO_MEDIA_TIME", +}); + +/** + * Construct a `MediaTime` from a known-integer tick count. Use `roundMediaTime` + * when the input may be fractional. + */ +export function mediaTime({ ticks }: { ticks: number }): MediaTime { + return requireMediaTime({ + value: ticks, + context: "mediaTime()", + }); +} + +/** + * Project a fractional value onto the integer-tick lattice. + * + * Rounds half away from zero (`-1.5 → -2`, `1.5 → 2`) and normalises `-0` to + * `0`. The away-from-zero rule matches Rust's `.round()` and avoids the + * `Math.round(-0.5) === -0` quirk that propagates `-0` into stored data. + */ +export function roundMediaTime({ time }: { time: number }): MediaTime { + const roundedMagnitude = Math.round(Math.abs(time)); + if (roundedMagnitude === 0) { + return ZERO_MEDIA_TIME; + } + return requireMediaTime({ + value: time < 0 ? -roundedMagnitude : roundedMagnitude, + context: "roundMediaTime()", + }); +} + +export function mediaTimeFromSeconds({ + seconds, +}: { + seconds: number; +}): MediaTime { + const result = _mediaTimeFromSeconds({ seconds }); + if (result === undefined) { + throw new Error( + `mediaTimeFromSeconds: rust returned undefined for seconds=${seconds}`, + ); + } + return requireMediaTime({ + value: result, + context: "mediaTimeFromSeconds()", + }); +} + +export function mediaTimeToSeconds({ time }: { time: MediaTime }): number { + return _mediaTimeToSeconds({ time }); +} + +/** + * Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too. + */ +export function addMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return requireMediaTime({ + value: a + b, + context: "addMediaTime()", + }); +} + +export function subMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return requireMediaTime({ + value: a - b, + context: "subMediaTime()", + }); +} + +export function maxMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return a > b ? a : b; +} + +export function minMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return a < b ? a : b; +} + +export function clampMediaTime({ + time, + min, + max, +}: { + time: MediaTime; + min: MediaTime; + max: MediaTime; +}): MediaTime { + if (time < min) return min; + if (time > max) return max; + return time; +} + +export function roundFrameTime({ + time, + fps, +}: { + time: MediaTime; + fps: FrameRate; +}): MediaTime { + return requireMediaTime({ + value: _roundToFrame({ time, rate: fps }) ?? time, + context: "roundFrameTime()", + }); +} + +export function roundFrameTicks({ + ticks, + fps, +}: { + ticks: number; + fps: FrameRate; +}): number { + return _roundToFrame({ time: ticks, rate: fps }) ?? ticks; +} + +export function snapSeekMediaTime({ + time, + duration, + fps, +}: { + time: MediaTime; + duration: MediaTime; + fps: FrameRate; +}): MediaTime { + return requireMediaTime({ + value: _snappedSeekTime({ time, duration, rate: fps }) ?? time, + context: "snapSeekMediaTime()", + }); +} + +export function lastFrameMediaTime({ + duration, + fps, +}: { + duration: MediaTime; + fps: FrameRate; +}): MediaTime { + return requireMediaTime({ + value: _lastFrameTime({ duration, rate: fps }) ?? duration, + context: "lastFrameMediaTime()", + }); +} + +export function parseMediaTimecode({ + timeCode, + format, + fps, +}: { + timeCode: string; + format: TimeCodeFormat; + fps: FrameRate; +}): MediaTime | null { + const parsedTime = _parseTimecode({ timeCode, format, rate: fps }); + if (parsedTime == null) { + return null; + } + return requireMediaTime({ + value: parsedTime, + context: "parseMediaTimecode()", + }); +} diff --git a/apps/web/src/wasm/ticks.ts b/apps/web/src/wasm/ticks.ts deleted file mode 100644 index ababe6b3..00000000 --- a/apps/web/src/wasm/ticks.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm"; - -export const TICKS_PER_SECOND = _TICKS_PER_SECOND(); |
