chore: switch from biome to eslint + prettier; fix ton of lint issues

This commit is contained in:
Maze Winther 2026-04-29 00:37:56 +02:00
parent d6622dc6b3
commit a9b9cf6bf5
159 changed files with 5630 additions and 5255 deletions

View File

@ -168,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
2. Make your changes 2. Make your changes
3. Run the relevant checks for the area you touched: 3. Run the relevant checks for the area you touched:
- Web changes: from `apps/web`, run `bun run lint` and `bunx biome format --write .` - Web changes: from `apps/web`, run `bun run lint` and `bun run format`
- Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet - Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet
4. Commit your changes with a descriptive message 4. Commit your changes with a descriptive message
@ -176,8 +176,8 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
## Code Style ## Code Style
- We use Biome for code formatting and linting - We use ESLint for linting and Prettier for formatting
- Run `bunx biome format --write .` from the `apps/web` directory to format code - Run `bun run format` from the `apps/web` directory to format code
- Run `bun run lint` from the `apps/web` directory to check for linting issues - Run `bun run lint` from the `apps/web` directory to check for linting issues
- Follow the existing code patterns - Follow the existing code patterns

View File

@ -4,12 +4,10 @@ applyTo: "**/*.{ts,tsx,js,jsx}"
# Project Context # Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. Strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects, enforced via ESLint (linting) and Prettier (formatting).
## Key Principles ## Key Principles
- Zero configuration required
- Subsecond performance
- Maximum type safety - Maximum type safety
- AI-friendly code generation - AI-friendly code generation

6
.prettierignore Normal file
View File

@ -0,0 +1,6 @@
.next
node_modules
dist
build
target
rust/wasm/pkg

3
.prettierrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"useTabs": true
}

11
.vscode/settings.json vendored
View File

@ -7,14 +7,7 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnPaste": true, "editor.formatOnPaste": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit", "source.fixAll.eslint": "explicit"
"source.organizeImports.biome": "explicit"
}, },
"emmet.showExpandedAbbreviation": "never", "emmet.showExpandedAbbreviation": "never"
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
} }

View File

@ -21,17 +21,3 @@ Each app is a frontend that calls into Rust. Logic is never duplicated between a
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them. - Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
### TypeScript
Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter:
```tsx
// ❌ meaning depends on order; the shape can't evolve without touching every caller
function formatTime(seconds: number, fps: number) { ... }
// ✅ each argument names itself; fields can be added, reordered, or made optional freely
function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
```
The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply.

View File

@ -9,9 +9,9 @@
"start": "next start", "start": "next start",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"lint": "biome check src/", "lint": "eslint src --ext .ts,.tsx",
"lint:fix": "biome check src/ --write", "lint:fix": "eslint src --ext .ts,.tsx --fix",
"format": "biome format src/ --write", "format": "prettier src --write",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push", "db:push:local": "cross-env NODE_ENV=development drizzle-kit push",

View File

@ -51,10 +51,10 @@ export function ShortcutsDialog({
const keyString = getKeybindingString(e); const keyString = getKeybindingString(e);
if (keyString) { if (keyString) {
const conflict = validateKeybinding( const conflict = validateKeybinding({
keyString, key: keyString,
recordingShortcut.action, action: recordingShortcut.action,
); });
if (conflict) { if (conflict) {
toast.error( toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`, `Key "${keyString}" is already bound to "${conflict.existingAction}"`,
@ -68,7 +68,10 @@ export function ShortcutsDialog({
removeKeybinding(key); removeKeybinding(key);
} }
updateKeybinding(keyString, recordingShortcut.action); updateKeybinding({
key: keyString,
action: recordingShortcut.action,
});
setIsRecording(false); setIsRecording(false);
setRecordingShortcut(null); setRecordingShortcut(null);

View File

@ -1,7 +1,4 @@
import type { import type { ShortcutKey } from "@/actions/keybinding";
KeybindingConfig,
ShortcutKey,
} from "@/actions/keybinding";
import type { TActionWithOptionalArgs } from "./types"; import type { TActionWithOptionalArgs } from "./types";
export type TActionCategory = export type TActionCategory =
@ -155,33 +152,36 @@ export const ACTIONS = {
export type TAction = keyof typeof ACTIONS; export type TAction = keyof typeof ACTIONS;
const ACTION_DEFAULT_SHORTCUTS = { const ACTION_DEFAULT_SHORTCUTS = [
"toggle-play": ["space", "k"], ["toggle-play", ["space", "k"]],
"seek-forward": ["l"], ["seek-forward", ["l"]],
"seek-backward": ["j"], ["seek-backward", ["j"]],
"frame-step-forward": ["right"], ["frame-step-forward", ["right"]],
"frame-step-backward": ["left"], ["frame-step-backward", ["left"]],
"jump-forward": ["shift+right"], ["jump-forward", ["shift+right"]],
"jump-backward": ["shift+left"], ["jump-backward", ["shift+left"]],
"goto-start": ["home", "enter"], ["goto-start", ["home", "enter"]],
"goto-end": ["end"], ["goto-end", ["end"]],
split: ["s"], ["split", ["s"]],
"split-left": ["q"], ["split-left", ["q"]],
"split-right": ["w"], ["split-right", ["w"]],
"delete-selected": ["backspace", "delete"], ["delete-selected", ["backspace", "delete"]],
"copy-selected": ["ctrl+c"], ["copy-selected", ["ctrl+c"]],
"paste-copied": ["ctrl+v"], ["paste-copied", ["ctrl+v"]],
"toggle-snapping": ["n"], ["toggle-snapping", ["n"]],
"select-all": ["ctrl+a"], ["select-all", ["ctrl+a"]],
"cancel-interaction": ["escape"], ["cancel-interaction", ["escape"]],
"duplicate-selected": ["ctrl+d"], ["duplicate-selected", ["ctrl+d"]],
undo: ["ctrl+z"], ["undo", ["ctrl+z"]],
redo: ["ctrl+shift+z", "ctrl+y"], ["redo", ["ctrl+shift+z", "ctrl+y"]],
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>; ] as const satisfies ReadonlyArray<
readonly [TActionWithOptionalArgs, readonly ShortcutKey[]]
>;
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map<
Record<TAction, readonly ShortcutKey[]> TAction,
> = ACTION_DEFAULT_SHORTCUTS; readonly ShortcutKey[]
>(ACTION_DEFAULT_SHORTCUTS);
export function getActionDefinition({ export function getActionDefinition({
action, action,
@ -190,18 +190,19 @@ export function getActionDefinition({
}): TActionDefinition { }): TActionDefinition {
return { return {
...ACTIONS[action], ...ACTIONS[action],
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action], defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action),
}; };
} }
export function getDefaultShortcuts(): KeybindingConfig { export function getDefaultShortcuts(): Map<
const shortcuts: KeybindingConfig = {}; ShortcutKey,
TActionWithOptionalArgs
> {
const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>();
for (const [action, defaultShortcuts] of Object.entries( for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) {
ACTION_DEFAULT_SHORTCUTS,
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
for (const shortcut of defaultShortcuts) { for (const shortcut of defaultShortcuts) {
shortcuts[shortcut] = action; shortcuts.set(shortcut, action);
} }
} }

View File

@ -13,60 +13,24 @@ export type ModifierKeys =
| "ctrl+alt" | "ctrl+alt"
| "ctrl+alt+shift"; | "ctrl+alt+shift";
export type Key = const KEYS = [
| "a" "a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
| "b" "k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
| "c" "u", "v", "w", "x", "y", "z",
| "d" "0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
| "e" "up", "down", "left", "right",
| "f" "/", "?", ".",
| "g" "enter", "tab", "space", "escape", "esc",
| "h" "backspace", "delete", "home", "end",
| "i" ] as const;
| "j"
| "k" export type Key = (typeof KEYS)[number];
| "l"
| "m" const KEY_SET: ReadonlySet<string> = new Set(KEYS);
| "n"
| "o" export function isKey(value: string): value is Key {
| "p" return KEY_SET.has(value);
| "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}`; export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
// Singular keybindings (these will be disabled when an input-ish area has been focused) // Singular keybindings (these will be disabled when an input-ish area has been focused)

View File

@ -6,11 +6,15 @@ import type { TActionWithOptionalArgs } from "@/actions";
import { getDefaultShortcuts } from "@/actions"; import { getDefaultShortcuts } from "@/actions";
import { isTypableDOMElement } from "@/utils/browser"; import { isTypableDOMElement } from "@/utils/browser";
import { isAppleDevice } from "@/utils/platform"; 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"; import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
export interface KeybindingConflict { export interface KeybindingConflict {
key: ShortcutKey; key: ShortcutKey;
existingAction: TActionWithOptionalArgs; existingAction: TActionWithOptionalArgs;
@ -18,38 +22,57 @@ export interface KeybindingConflict {
} }
interface KeybindingsState { interface KeybindingsState {
keybindings: KeybindingConfig; keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
isCustomized: boolean; isCustomized: boolean;
overlayDepth: number; overlayDepth: number;
openOverlayIds: string[]; openOverlayIds: string[];
isLoadingProject: boolean; isLoadingProject: boolean;
isRecording: boolean; isRecording: boolean;
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; updateKeybinding: (params: {
key: ShortcutKey;
action: TActionWithOptionalArgs;
}) => void;
removeKeybinding: (key: ShortcutKey) => void; removeKeybinding: (key: ShortcutKey) => void;
resetToDefaults: () => void; resetToDefaults: () => void;
importKeybindings: (config: KeybindingConfig) => void; importKeybindings: (config: KeybindingConfig) => void;
exportKeybindings: () => KeybindingConfig; exportKeybindings: () => Record<string, TActionWithOptionalArgs>;
openOverlay: (overlayId: string) => void; openOverlay: (overlayId: string) => void;
closeOverlay: (overlayId: string) => void; closeOverlay: (overlayId: string) => void;
setLoadingProject: (loading: boolean) => void; setLoadingProject: (loading: boolean) => void;
setIsRecording: (isRecording: boolean) => void; setIsRecording: (isRecording: boolean) => void;
validateKeybinding: ( validateKeybinding: (params: {
key: ShortcutKey, key: ShortcutKey;
action: TActionWithOptionalArgs, action: TActionWithOptionalArgs;
) => KeybindingConflict | null; }) => KeybindingConflict | null;
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
} }
type PersistedState = {
keybindings: Record<string, TActionWithOptionalArgs>;
isCustomized: boolean;
};
function isDOMElement(element: EventTarget | null): element is HTMLElement { function isDOMElement(element: EventTarget | null): element is HTMLElement {
return element instanceof 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>()( export const useKeybindingsStore = create<KeybindingsState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
keybindings: { ...defaultKeybindings }, keybindings: getDefaultShortcuts(),
isCustomized: false, isCustomized: false,
overlayDepth: 0, overlayDepth: 0,
openOverlayIds: [], openOverlayIds: [],
@ -61,10 +84,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
const openOverlayIds = s.openOverlayIds.includes(overlayId) const openOverlayIds = s.openOverlayIds.includes(overlayId)
? s.openOverlayIds ? s.openOverlayIds
: [...s.openOverlayIds, overlayId]; : [...s.openOverlayIds, overlayId];
const nextOverlayDepth = openOverlayIds.length;
return { return {
openOverlayIds, openOverlayIds,
overlayDepth: nextOverlayDepth, overlayDepth: openOverlayIds.length,
}; };
}), }),
closeOverlay: (overlayId) => closeOverlay: (overlayId) =>
@ -72,35 +94,32 @@ export const useKeybindingsStore = create<KeybindingsState>()(
const openOverlayIds = s.openOverlayIds.filter( const openOverlayIds = s.openOverlayIds.filter(
(id) => id !== overlayId, (id) => id !== overlayId,
); );
const nextOverlayDepth = openOverlayIds.length;
return { return {
openOverlayIds, openOverlayIds,
overlayDepth: nextOverlayDepth, overlayDepth: openOverlayIds.length,
}; };
}), }),
setLoadingProject: (loading) => { setLoadingProject: (loading) => {
set({ isLoadingProject: loading }); set({ isLoadingProject: loading });
}, },
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { updateKeybinding: ({ key, action }) => {
set((state) => { set((state) => {
const newKeybindings = { ...state.keybindings }; const next = new Map(state.keybindings);
newKeybindings[key] = action; next.set(key, action);
return { return {
keybindings: newKeybindings, keybindings: next,
isCustomized: true, isCustomized: true,
}; };
}); });
}, },
removeKeybinding: (key: ShortcutKey) => { removeKeybinding: (key) => {
set((state) => { set((state) => {
const newKeybindings = { ...state.keybindings }; const next = new Map(state.keybindings);
delete newKeybindings[key]; next.delete(key);
return { return {
keybindings: newKeybindings, keybindings: next,
isCustomized: true, isCustomized: true,
}; };
}); });
@ -108,34 +127,35 @@ export const useKeybindingsStore = create<KeybindingsState>()(
resetToDefaults: () => { resetToDefaults: () => {
set({ set({
keybindings: { ...defaultKeybindings }, keybindings: getDefaultShortcuts(),
isCustomized: false, isCustomized: false,
}); });
}, },
importKeybindings: (config: KeybindingConfig) => { importKeybindings: (config) => {
for (const [key] of Object.entries(config)) { const next = new Map<ShortcutKey, TActionWithOptionalArgs>();
for (const [key, action] of Object.entries(config)) {
if (typeof key !== "string" || key.length === 0) { if (typeof key !== "string" || key.length === 0) {
throw new Error(`Invalid key format: ${key}`); 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({ set({
keybindings: { ...config }, keybindings: next,
isCustomized: true, isCustomized: true,
}); });
}, },
exportKeybindings: () => { exportKeybindings: () => {
return get().keybindings; return Object.fromEntries(get().keybindings);
}, },
validateKeybinding: ( validateKeybinding: ({ key, action }) => {
key: ShortcutKey, const existingAction = get().keybindings.get(key);
action: TActionWithOptionalArgs,
) => {
const { keybindings } = get();
const existingAction = keybindings[key];
if (existingAction && existingAction !== action) { if (existingAction && existingAction !== action) {
return { return {
key, key,
@ -143,33 +163,45 @@ export const useKeybindingsStore = create<KeybindingsState>()(
newAction: action, newAction: action,
}; };
} }
return null; return null;
}, },
setIsRecording: (isRecording: boolean) => { setIsRecording: (isRecording) => {
set({ isRecording }); set({ isRecording });
}, },
getKeybindingsForAction: (action: TActionWithOptionalArgs) => { getKeybindingsForAction: (action) => {
const { keybindings } = get(); const result: ShortcutKey[] = [];
return Object.keys(keybindings).filter( for (const [key, mapped] of get().keybindings) {
(key) => keybindings[key as ShortcutKey] === action, if (mapped === action) result.push(key);
) as ShortcutKey[]; }
return result;
}, },
getKeybindingString: (ev: KeyboardEvent) => { getKeybindingString: (ev) => generateKeybindingString(ev),
return generateKeybindingString(ev) as ShortcutKey | null;
},
}), }),
{ {
name: "opencut-keybindings", name: "opencut-keybindings",
version: CURRENT_VERSION, version: CURRENT_VERSION,
partialize: (state) => ({ partialize: (state): PersistedState => ({
keybindings: state.keybindings, keybindings: Object.fromEntries(state.keybindings),
isCustomized: state.isCustomized, isCustomized: state.isCustomized,
}), }),
migrate: (persisted, version) => migrate: (persisted, version) =>
runMigrations({ state: persisted, fromVersion: 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 ( if (
modifierKey === "shift" && modifierKey === "shift" &&
isDOMElement(target) && isDOMElement(target) &&
isTypableDOMElement({ element: target as HTMLElement }) isTypableDOMElement({ element: target })
) { ) {
return null; return null;
} }
return `${modifierKey}+${key}` as ShortcutKey; return `${modifierKey}+${key}`;
} }
if ( if (isDOMElement(target) && isTypableDOMElement({ element: target })) {
isDOMElement(target) &&
isTypableDOMElement({ element: target as HTMLElement })
)
return null; return null;
}
return `${key}` as ShortcutKey; return key;
} }
function getPressedKey(ev: KeyboardEvent): string | null { function getPressedKey(ev: KeyboardEvent): Key | null {
const key = (ev.key ?? "").toLowerCase(); const raw = (ev.key ?? "").toLowerCase();
const code = ev.code ?? ""; const code = ev.code ?? "";
if (code === "Space" || key === " " || key === "spacebar" || key === "space") if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space")
return "space"; return "space";
if (key.startsWith("arrow")) return key.slice(5); if (raw === "arrowup") return "up";
if (raw === "arrowdown") return "down";
if (key === "escape") return "escape"; if (raw === "arrowleft") return "left";
if (key === "tab") return "tab"; if (raw === "arrowright") return "right";
if (key === "home") return "home";
if (key === "end") return "end";
if (key === "delete") return "delete";
if (key === "backspace") return "backspace";
if (code.startsWith("Key")) { if (code.startsWith("Key")) {
const letter = code.slice(3).toLowerCase(); 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")) { if (code.startsWith("Digit")) {
const digit = code.slice(5); 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 (isKey(raw)) return raw;
if (isDigit) return key;
if (key === "/" || key === "." || key === "enter") return key;
return null; return null;
} }
function getActiveModifier(ev: KeyboardEvent): string | null { function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null {
const modifierKeys = { const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey;
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, const alt = ev.altKey;
alt: ev.altKey, const shift = ev.shiftKey;
shift: ev.shiftKey,
};
const activeModifier = Object.keys(modifierKeys) if (ctrl && alt && shift) return "ctrl+alt+shift";
.filter((key) => modifierKeys[key as keyof typeof modifierKeys]) if (ctrl && alt) return "ctrl+alt";
.join("+"); if (ctrl && shift) return "ctrl+shift";
if (alt && shift) return "alt+shift";
return activeModifier === "" ? null : activeModifier; if (ctrl) return "ctrl";
if (alt) return "alt";
if (shift) return "shift";
return null;
} }

View File

@ -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/);
});
});

View File

@ -1,13 +1,8 @@
import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; import { getPersistedKeybindingsState } from "../persisted-state";
import type { TActionWithOptionalArgs } from "@/actions";
interface V2State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v2ToV3({ state }: { state: unknown }): unknown { 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> = { const renames: Record<string, string> = {
"split-selected": "split", "split-selected": "split",
@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown {
const migrated = { ...v2.keybindings }; const migrated = { ...v2.keybindings };
for (const [key, action] of Object.entries(migrated)) { for (const [key, action] of Object.entries(migrated)) {
if (action && renames[action]) { const renamedAction = action ? renames[action] : undefined;
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; if (renamedAction) {
migrated[key] = renamedAction;
} }
} }

View File

@ -1,14 +1,8 @@
import type { TActionWithOptionalArgs } from "@/actions"; import { getPersistedKeybindingsState } from "../persisted-state";
import type { ShortcutKey } from "@/actions/keybinding";
import type { KeybindingConfig } from "@/actions/keybinding";
interface V3State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v3ToV4({ state }: { state: unknown }): unknown { 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> = { const renames: Record<string, string> = {
"paste-selected": "paste-copied", "paste-selected": "paste-copied",
@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown {
const migrated = { ...v3.keybindings }; const migrated = { ...v3.keybindings };
for (const [key, action] of Object.entries(migrated)) { for (const [key, action] of Object.entries(migrated)) {
if (action && renames[action]) { const renamedAction = action ? renames[action] : undefined;
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; if (renamedAction) {
migrated[key] = renamedAction;
} }
} }

View File

@ -1,12 +1,8 @@
import type { KeybindingConfig } from "@/actions/keybinding"; import { getPersistedKeybindingsState } from "../persisted-state";
interface V4State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v4ToV5({ state }: { state: unknown }): unknown { export function v4ToV5({ state }: { state: unknown }): unknown {
const v4 = state as V4State; const v4 = getPersistedKeybindingsState({ state });
if (!v4) return state;
const keybindings = { ...v4.keybindings }; const keybindings = { ...v4.keybindings };
if (!keybindings.escape) { if (!keybindings.escape) {

View File

@ -1,12 +1,8 @@
import type { KeybindingConfig } from "@/actions/keybinding"; import { getPersistedKeybindingsState } from "../persisted-state";
interface V5State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v5ToV6({ state }: { state: unknown }): unknown { export function v5ToV6({ state }: { state: unknown }): unknown {
const v5 = state as V5State; const v5 = getPersistedKeybindingsState({ state });
if (!v5) return state;
const keybindings = { ...v5.keybindings }; const keybindings = { ...v5.keybindings };
if (keybindings.escape === "deselect-all") { if (keybindings.escape === "deselect-all") {

View File

@ -1,16 +1,12 @@
import type { KeybindingConfig } from "@/actions/keybinding"; import { getPersistedKeybindingsState } from "../persisted-state";
interface V6State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v6ToV7({ state }: { state: unknown }): unknown { export function v6ToV7({ state }: { state: unknown }): unknown {
const v6 = state as V6State; const v6 = getPersistedKeybindingsState({ state });
if (!v6) return state;
const keybindings = { ...v6.keybindings }; const keybindings = { ...v6.keybindings };
for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) { for (const [key, action] of Object.entries(keybindings)) {
if (keybindings[key] === ("split-element" as never)) { if (action === "split-element") {
keybindings[key] = "split"; keybindings[key] = "split";
} }
} }

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -11,6 +11,7 @@ import type {
type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void; type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
const boundActions: Partial<Record<TAction, ActionHandler[]>> = {}; 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>( export function bindAction<A extends TAction>(
action: A, action: A,
handler: TActionFunc<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>( export function unbindAction<A extends TAction>(
action: A, action: A,
handler: TActionFunc<A>, handler: TActionFunc<A>,
@ -52,6 +54,7 @@ type InvokeActionFunc = {
): void; ): void;
}; };
// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger.
export const invokeAction: InvokeActionFunc = <A extends TAction>( export const invokeAction: InvokeActionFunc = <A extends TAction>(
action: A, action: A,
args?: TArgOfAction<A>, args?: TArgOfAction<A>,

View File

@ -8,6 +8,7 @@ import type {
} from "@/actions"; } from "@/actions";
import { bindAction, unbindAction } 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>( export function useActionHandler<A extends TAction>(
action: A, action: A,
handler: TActionFunc<A>, handler: TActionFunc<A>,

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useState } from "react";
import { useTimelineStore } from "@/timeline/timeline-store"; import { useTimelineStore } from "@/timeline/timeline-store";
import { useActionHandler } from "@/actions/use-action-handler"; import { useActionHandler } from "@/actions/use-action-handler";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
@ -25,6 +25,7 @@ import {
clearActiveScope, clearActiveScope,
type ScopeEntry, type ScopeEntry,
} from "@/selection/scope"; } from "@/selection/scope";
import { useCommittedRef } from "@/hooks/use-committed-ref";
export function useEditorActions() { export function useEditorActions() {
const editor = useEditor(); const editor = useEditor();
@ -36,47 +37,34 @@ export function useEditorActions() {
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
const hasTimelineSelectionRef = useRef(false);
const clearTimelineSelectionRef = useRef(() => {});
const clearTimelineActiveSelectionRef = useRef(() => {});
const timelineScopeRef = useRef<ScopeEntry | null>(null);
const hasTimelineSelection = const hasTimelineSelection =
selectedElements.length > 0 || selectedElements.length > 0 ||
selectedKeyframes.length > 0 || selectedKeyframes.length > 0 ||
selectedMaskPointSelection !== null; selectedMaskPointSelection !== null;
const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection);
hasTimelineSelectionRef.current = hasTimelineSelection; const clearTimelineSelectionRef = useCommittedRef(() => {
clearTimelineSelectionRef.current = () => {
editor.selection.clearSelection(); editor.selection.clearSelection();
}; });
clearTimelineActiveSelectionRef.current = () => { const clearTimelineActiveSelectionRef = useCommittedRef(() => {
editor.selection.clearMostSpecificSelection(); editor.selection.clearMostSpecificSelection();
}; });
const [timelineScope] = useState<ScopeEntry>(() => ({
if (!timelineScopeRef.current) { hasSelection: () => hasTimelineSelectionRef.current,
timelineScopeRef.current = { clear: () => {
hasSelection: () => hasTimelineSelectionRef.current, clearTimelineSelectionRef.current();
clear: () => { },
clearTimelineSelectionRef.current(); clearActive: () => {
}, clearTimelineActiveSelectionRef.current();
clearActive: () => { },
clearTimelineActiveSelectionRef.current(); }));
},
};
}
useEffect(() => { useEffect(() => {
if (!hasTimelineSelection) { if (!hasTimelineSelection) {
return; return;
} }
const timelineScope = timelineScopeRef.current;
if (!timelineScope) {
return;
}
return activateScope({ entry: timelineScope }); return activateScope({ entry: timelineScope });
}, [hasTimelineSelection]); }, [hasTimelineSelection, timelineScope]);
useActionHandler( useActionHandler(
"toggle-play", "toggle-play",

View File

@ -33,7 +33,7 @@ export function useKeybindingsListener() {
const isTextInput = const isTextInput =
activeElement instanceof HTMLElement && activeElement instanceof HTMLElement &&
isTypableDOMElement({ element: activeElement }); isTypableDOMElement({ element: activeElement });
const boundAction = binding ? keybindings[binding] : undefined; const boundAction = binding ? keybindings.get(binding) : undefined;
if (normalizedKey === "escape" && isTextInput) { if (normalizedKey === "escape" && isTextInput) {
activeElement.blur(); activeElement.blur();

View File

@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() {
const { keybindings } = useKeybindingsStore(); const { keybindings } = useKeybindingsStore();
const shortcuts = useMemo(() => { const shortcuts = useMemo(() => {
const result: KeyboardShortcut[] = []; const actionToKeys = new Map<TActionWithOptionalArgs, string[]>();
const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {};
for (const [key, action] of Object.entries(keybindings) as Array< for (const [key, action] of keybindings) {
[string, TActionWithOptionalArgs | undefined] const existing = actionToKeys.get(action);
>) { if (existing) {
if (action) { existing.push(formatKey({ key }));
if (!actionToKeys[action]) { } else {
actionToKeys[action] = []; actionToKeys.set(action, [formatKey({ key })]);
}
actionToKeys[action].push(formatKey({ key }));
} }
} }
for (const [action, keys] of Object.entries(actionToKeys) as Array< const result: KeyboardShortcut[] = [];
[TActionWithOptionalArgs, string[]] for (const [action, keys] of actionToKeys) {
>) {
const actionDef = ACTIONS[action]; const actionDef = ACTIONS[action];
if (!actionDef) { if (!actionDef) continue;
continue;
}
result.push({ result.push({
id: action, id: action,
keys, keys,

View File

@ -32,7 +32,11 @@ export interface AnimationPropertyDefinition {
supportsElement: ({ element }: { element: TimelineElement }) => boolean; supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
setValue: ({ // Apply `value` to `element` for this property. Coerces the value through
// `coerceValue` and verifies element support; returns `element` unchanged
// if either fails. Cannot be bypassed — there is no kind-narrow `setValue`
// on the public surface, so callers can't apply an unvalidated value.
applyValue: ({
element, element,
value, value,
}: { }: {
@ -94,7 +98,13 @@ function createNumberPropertyDefinition({
numericRange?: NumericSpec; numericRange?: NumericSpec;
supportsElement: AnimationPropertyDefinition["supportsElement"]; supportsElement: AnimationPropertyDefinition["supportsElement"];
getValue: AnimationPropertyDefinition["getValue"]; getValue: AnimationPropertyDefinition["getValue"];
setValue: AnimationPropertyDefinition["setValue"]; setValue: ({
element,
value,
}: {
element: TimelineElement;
value: number;
}) => TimelineElement;
}): AnimationPropertyDefinition { }): AnimationPropertyDefinition {
return { return {
kind: "number", kind: "number",
@ -102,12 +112,51 @@ function createNumberPropertyDefinition({
numericRanges: numericRange ? { value: numericRange } : undefined, numericRanges: numericRange ? { value: numericRange } : undefined,
supportsElement, supportsElement,
getValue, getValue,
coerceValue: ({ value }) => coerceValue: ({ value }) => coerceNumberValue({ value, numericRange }),
coerceNumberValue({ applyValue: ({ element, value }) => {
value, if (!supportsElement({ element })) {
numericRange, return element;
}), }
setValue, const coerced = coerceNumberValue({ value, numericRange });
if (coerced === null) {
return element;
}
return setValue({ element, value: coerced });
},
};
}
function createColorPropertyDefinition({
supportsElement,
getValue,
setValue,
}: {
supportsElement: AnimationPropertyDefinition["supportsElement"];
getValue: AnimationPropertyDefinition["getValue"];
setValue: ({
element,
value,
}: {
element: TimelineElement;
value: string;
}) => TimelineElement;
}): AnimationPropertyDefinition {
return {
kind: "color",
defaultInterpolation: "linear",
supportsElement,
getValue,
coerceValue: ({ value }) => coerceColorValue({ value }),
applyValue: ({ element, value }) => {
if (!supportsElement({ element })) {
return element;
}
const coerced = coerceColorValue({ value });
if (coerced === null) {
return element;
}
return setValue({ element, value: coerced });
},
}; };
} }
@ -126,7 +175,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
...element, ...element,
transform: { transform: {
...element.transform, ...element.transform,
position: { ...element.transform.position, x: value as number }, position: { ...element.transform.position, x: value },
}, },
} }
: element, : element,
@ -142,7 +191,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
...element, ...element,
transform: { transform: {
...element.transform, ...element.transform,
position: { ...element.transform.position, y: value as number }, position: { ...element.transform.position, y: value },
}, },
} }
: element, : element,
@ -156,7 +205,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
isVisualElement(element) isVisualElement(element)
? { ? {
...element, ...element,
transform: { ...element.transform, scaleX: value as number }, transform: { ...element.transform, scaleX: value },
} }
: element, : element,
}), }),
@ -169,7 +218,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
isVisualElement(element) isVisualElement(element)
? { ? {
...element, ...element,
transform: { ...element.transform, scaleY: value as number }, transform: { ...element.transform, scaleY: value },
} }
: element, : element,
}), }),
@ -182,7 +231,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
isVisualElement(element) isVisualElement(element)
? { ? {
...element, ...element,
transform: { ...element.transform, rotate: value as number }, transform: { ...element.transform, rotate: value },
} }
: element, : element,
}), }),
@ -192,9 +241,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
getValue: ({ element }) => getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null, isVisualElement(element) ? element.opacity : null,
setValue: ({ element, value }) => setValue: ({ element, value }) =>
isVisualElement(element) isVisualElement(element) ? { ...element, opacity: value } : element,
? { ...element, opacity: value as number }
: element,
}), }),
volume: createNumberPropertyDefinition({ volume: createNumberPropertyDefinition({
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
@ -202,36 +249,26 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
getValue: ({ element }) => getValue: ({ element }) =>
canElementHaveAudio(element) ? element.volume ?? 0 : null, canElementHaveAudio(element) ? element.volume ?? 0 : null,
setValue: ({ element, value }) => setValue: ({ element, value }) =>
canElementHaveAudio(element) canElementHaveAudio(element) ? { ...element, volume: value } : element,
? { ...element, volume: value as number }
: element,
}), }),
color: { color: createColorPropertyDefinition({
kind: "color",
defaultInterpolation: "linear",
supportsElement: ({ element }) => element.type === "text", supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) => (element.type === "text" ? element.color : null), getValue: ({ element }) => (element.type === "text" ? element.color : null),
coerceValue: ({ value }) => coerceColorValue({ value }),
setValue: ({ element, value }) => setValue: ({ element, value }) =>
element.type === "text" element.type === "text" ? { ...element, color: value } : element,
? { ...element, color: value as string } }),
: element, "background.color": createColorPropertyDefinition({
},
"background.color": {
kind: "color",
defaultInterpolation: "linear",
supportsElement: ({ element }) => element.type === "text", supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) => getValue: ({ element }) =>
element.type === "text" ? element.background.color : null, element.type === "text" ? element.background.color : null,
coerceValue: ({ value }) => coerceColorValue({ value }),
setValue: ({ element, value }) => setValue: ({ element, value }) =>
element.type === "text" element.type === "text"
? { ? {
...element, ...element,
background: { ...element.background, color: value as string }, background: { ...element.background, color: value },
} }
: element, : element,
}, }),
"background.paddingX": createNumberPropertyDefinition({ "background.paddingX": createNumberPropertyDefinition({
numericRange: { min: 0, step: 1 }, numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text", supportsElement: ({ element }) => element.type === "text",
@ -243,7 +280,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
element.type === "text" element.type === "text"
? { ? {
...element, ...element,
background: { ...element.background, paddingX: value as number }, background: { ...element.background, paddingX: value },
} }
: element, : element,
}), }),
@ -258,7 +295,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
element.type === "text" element.type === "text"
? { ? {
...element, ...element,
background: { ...element.background, paddingY: value as number }, background: { ...element.background, paddingY: value },
} }
: element, : element,
}), }),
@ -273,7 +310,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
element.type === "text" element.type === "text"
? { ? {
...element, ...element,
background: { ...element.background, offsetX: value as number }, background: { ...element.background, offsetX: value },
} }
: element, : element,
}), }),
@ -288,7 +325,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
element.type === "text" element.type === "text"
? { ? {
...element, ...element,
background: { ...element.background, offsetY: value as number }, background: { ...element.background, offsetY: value },
} }
: element, : element,
}), }),
@ -307,7 +344,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
element.type === "text" element.type === "text"
? { ? {
...element, ...element,
background: { ...element.background, cornerRadius: value as number }, background: { ...element.background, cornerRadius: value },
} }
: element, : element,
}), }),
@ -362,11 +399,7 @@ export function withElementBaseValueForProperty({
value: AnimationValue; value: AnimationValue;
}): TimelineElement { }): TimelineElement {
const definition = getAnimationPropertyDefinition({ propertyPath }); const definition = getAnimationPropertyDefinition({ propertyPath });
const coercedValue = definition.coerceValue({ value }); return definition.applyValue({ element, value });
if (coercedValue === null || !definition.supportsElement({ element })) {
return element;
}
return definition.setValue({ element, value: coercedValue });
} }
export function getDefaultInterpolationForProperty({ export function getDefaultInterpolationForProperty({

View File

@ -190,7 +190,7 @@ export default function BrandPage() {
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2 className="font-semibold text-lg">What's not allowed</h2> <h2 className="font-semibold text-lg">What&apos;s not allowed</h2>
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed"> <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.", "Using OpenCut in the name of your product, service, or domain.",

View File

@ -130,8 +130,14 @@ function EditorLayout() {
direction="vertical" direction="vertical"
className="size-full gap-[0.18rem]" className="size-full gap-[0.18rem]"
onLayout={(sizes) => { onLayout={(sizes) => {
setPanel("mainContent", sizes[0] ?? panels.mainContent); setPanel({
setPanel("timeline", sizes[1] ?? panels.timeline); panel: "mainContent",
size: sizes[0] ?? panels.mainContent,
});
setPanel({
panel: "timeline",
size: sizes[1] ?? panels.timeline,
});
}} }}
> >
<ResizablePanel <ResizablePanel
@ -144,9 +150,12 @@ function EditorLayout() {
direction="horizontal" direction="horizontal"
className="size-full gap-[0.19rem] px-3" className="size-full gap-[0.19rem] px-3"
onLayout={(sizes) => { onLayout={(sizes) => {
setPanel("tools", sizes[0] ?? panels.tools); setPanel({ panel: "tools", size: sizes[0] ?? panels.tools });
setPanel("preview", sizes[1] ?? panels.preview); setPanel({ panel: "preview", size: sizes[1] ?? panels.preview });
setPanel("properties", sizes[2] ?? panels.properties); setPanel({
panel: "properties",
size: sizes[2] ?? panels.properties,
});
}} }}
> >
<ResizablePanel <ResizablePanel

View File

@ -57,7 +57,10 @@ export default function PrivacyPage() {
content is tracked content is tracked
</li> </li>
<li>You can clear local data from your browser at any time</li> <li>You can clear local data from your browser at any time</li>
<li>We don't sell or share your data with anyone (we don't even have it)</li> <li>
We don&apos;t sell or share your data with anyone (we don&apos;t
even have it)
</li>
</ol> </ol>
<p className="mt-4"> <p className="mt-4">
Questions? Email us at{" "} Questions? Email us at{" "}
@ -172,7 +175,7 @@ export default function PrivacyPage() {
<a <a
href={SOCIAL_LINKS.github} href={SOCIAL_LINKS.github}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
GitHub GitHub
@ -189,7 +192,7 @@ export default function PrivacyPage() {
<a <a
href={`${SOCIAL_LINKS.github}/issues`} href={`${SOCIAL_LINKS.github}/issues`}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
GitHub repository GitHub repository
@ -205,7 +208,7 @@ export default function PrivacyPage() {
<a <a
href={SOCIAL_LINKS.x} href={SOCIAL_LINKS.x}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
X (Twitter) X (Twitter)

View File

@ -51,9 +51,13 @@ export default function TermsPage() {
Free for personal and commercial use with no watermarks or Free for personal and commercial use with no watermarks or
restrictions restrictions
</li> </li>
<li>You're responsible for how you use it - don't break the law</li>
<li> <li>
Service provided "as is" - we can't guarantee perfect uptime You&apos;re responsible for how you use it - don&apos;t break
the law
</li>
<li>
Service provided &quot;as is&quot; - we can&apos;t guarantee
perfect uptime
</li> </li>
<li> <li>
Open source means you can review our code and self-host if Open source means you can review our code and self-host if
@ -109,8 +113,8 @@ export default function TermsPage() {
</li> </li>
</ul> </ul>
<p> <p>
You're responsible for how you use OpenCut and the content you create. You&apos;re responsible for how you use OpenCut and the content you
Don't use it for anything illegal in your jurisdiction. create. Don&apos;t use it for anything illegal in your jurisdiction.
</p> </p>
</section> </section>
@ -127,8 +131,8 @@ export default function TermsPage() {
<h2 className="text-2xl font-semibold">Service</h2> <h2 className="text-2xl font-semibold">Service</h2>
<p> <p>
OpenCut does not currently require an account. The service is provided OpenCut does not currently require an account. The service is provided
"as is" without warranties. While we strive for reliability, we can't &quot;as is&quot; without warranties. While we strive for
guarantee uninterrupted service. reliability, we can&apos;t guarantee uninterrupted service.
</p> </p>
</section> </section>
@ -146,7 +150,7 @@ export default function TermsPage() {
<a <a
href={SOCIAL_LINKS.github} href={SOCIAL_LINKS.github}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
GitHub GitHub
@ -161,12 +165,12 @@ export default function TermsPage() {
OpenCut is provided free of charge. To the extent permitted by law: OpenCut is provided free of charge. To the extent permitted by law:
</p> </p>
<ul className="list-disc space-y-2 pl-6"> <ul className="list-disc space-y-2 pl-6">
<li>We're not liable for any loss of data or content</li> <li>We&apos;re not liable for any loss of data or content</li>
<li> <li>
Projects are stored in your browser and may be lost if you clear Projects are stored in your browser and may be lost if you clear
browser data browser data
</li> </li>
<li>We're not responsible for how you use the service</li> <li>We&apos;re not responsible for how you use the service</li>
<li>Our liability is limited to the maximum extent allowed by law</li> <li>Our liability is limited to the maximum extent allowed by law</li>
</ul> </ul>
<p> <p>
@ -180,7 +184,7 @@ export default function TermsPage() {
<h2 className="text-2xl font-semibold">Service Changes</h2> <h2 className="text-2xl font-semibold">Service Changes</h2>
<p>We may update OpenCut and these terms:</p> <p>We may update OpenCut and these terms:</p>
<ul className="list-disc space-y-2 pl-6"> <ul className="list-disc space-y-2 pl-6">
<li>We'll notify you of significant changes to these terms</li> <li>We&apos;ll notify you of significant changes to these terms</li>
<li>Continued use means you accept any updates</li> <li>Continued use means you accept any updates</li>
<li>You can always self-host an older version if you prefer</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> <li>Major changes will be discussed with the community on GitHub</li>
@ -203,7 +207,7 @@ export default function TermsPage() {
<a <a
href={`${SOCIAL_LINKS.github}/issues`} href={`${SOCIAL_LINKS.github}/issues`}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
GitHub repository GitHub repository
@ -219,7 +223,7 @@ export default function TermsPage() {
<a <a
href={SOCIAL_LINKS.x} href={SOCIAL_LINKS.x}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
X (Twitter) X (Twitter)

View File

@ -35,7 +35,7 @@ export const ElementsClipboardHandler = {
}; };
}, },
paste(entry, context) { paste({ entry, context }) {
if (entry.items.length === 0) { if (entry.items.length === 0) {
return null; return null;
} }

View File

@ -45,5 +45,5 @@ export function buildPasteClipboardCommand({
const handler = clipboardHandlers[entry.type] as ClipboardHandler< const handler = clipboardHandlers[entry.type] as ClipboardHandler<
typeof entry.type typeof entry.type
>; >;
return handler.paste(entry as never, context); return handler.paste({ entry: entry as never, context });
} }

View File

@ -156,7 +156,10 @@ export const KeyframesClipboardHandler = {
}; };
}, },
paste(entry, { selectedElements, time }) { paste({
entry,
context: { selectedElements, time },
}) {
const targetElement = selectedElements[0]; const targetElement = selectedElements[0];
if (!targetElement || entry.items.length === 0) { if (!targetElement || entry.items.length === 0) {
return null; return null;

View File

@ -69,10 +69,10 @@ export interface ClipboardHandler<TType extends ClipboardEntryType> {
type: TType; type: TType;
canCopy(context: CopyContext): boolean; canCopy(context: CopyContext): boolean;
copy(context: CopyContext): ClipboardEntryByType[TType] | null; copy(context: CopyContext): ClipboardEntryByType[TType] | null;
paste( paste(args: {
entry: ClipboardEntryByType[TType], entry: ClipboardEntryByType[TType];
context: PasteContext, context: PasteContext;
): Command | null; }): Command | null;
} }
export type ClipboardHandlerMap = { export type ClipboardHandlerMap = {

View File

@ -16,14 +16,22 @@ export class AddMediaAssetCommand extends Command {
private previousProjectFps: FrameRate | null = null; private previousProjectFps: FrameRate | null = null;
private appliedProjectFps: FrameRate | null = null; private appliedProjectFps: FrameRate | null = null;
constructor( constructor({
private projectId: string, projectId,
private asset: Omit<MediaAsset, "id">, asset,
) { }: {
projectId: string;
asset: Omit<MediaAsset, "id">;
}) {
super(); super();
this.projectId = projectId;
this.asset = asset;
this.assetId = generateUUID(); this.assetId = generateUUID();
} }
private projectId: string;
private asset: Omit<MediaAsset, "id">;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedAssets = [...editor.media.getAssets()]; this.savedAssets = [...editor.media.getAssets()];
@ -117,7 +125,14 @@ export class AddMediaAssetCommand extends Command {
const activeProject = editor.project.getActiveOrNull(); const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return; 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({ const highestRemainingVideoFps = getHighestImportedVideoFps({
mediaAssets: editor.media.getAssets(), mediaAssets: editor.media.getAssets(),

View File

@ -13,13 +13,21 @@ export class RemoveMediaAssetCommand extends Command {
private savedTracks: SceneTracks | null = null; private savedTracks: SceneTracks | null = null;
private removedAsset: MediaAsset | null = null; private removedAsset: MediaAsset | null = null;
constructor( constructor({
private projectId: string, projectId,
private assetId: string, assetId,
) { }: {
projectId: string;
assetId: string;
}) {
super(); super();
this.projectId = projectId;
this.assetId = assetId;
} }
private projectId: string;
private assetId: string;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const assets = editor.media.getAssets(); const assets = editor.media.getAssets();

View File

@ -7,13 +7,21 @@ export class CreateSceneCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private createdScene: TScene | null = null; private createdScene: TScene | null = null;
constructor( constructor({
private name: string, name,
private isMain: boolean = false, isMain = false,
) { }: {
name: string;
isMain?: boolean;
}) {
super(); super();
this.name = name;
this.isMain = isMain;
} }
private name: string;
private isMain: boolean;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedScenes = [...editor.scenes.getScenes()]; this.savedScenes = [...editor.scenes.getScenes()];

View File

@ -8,13 +8,21 @@ import type { MediaTime } from "@/wasm";
export class MoveBookmarkCommand extends Command { export class MoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
constructor( constructor({
private fromTime: MediaTime, fromTime,
private toTime: MediaTime, toTime,
) { }: {
fromTime: MediaTime;
toTime: MediaTime;
}) {
super(); super();
this.fromTime = fromTime;
this.toTime = toTime;
} }
private fromTime: MediaTime;
private toTime: MediaTime;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();

View File

@ -6,11 +6,11 @@ import {
getFrameTime, getFrameTime,
removeBookmarkFromArray, removeBookmarkFromArray,
} from "@/timeline/bookmarks/index"; } from "@/timeline/bookmarks/index";
import type { MediaTime } from "@/wasm"; import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export class RemoveBookmarkCommand extends Command { export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private frameTime: MediaTime = 0 as MediaTime; private frameTime: MediaTime = ZERO_MEDIA_TIME;
constructor(private time: MediaTime) { constructor(private time: MediaTime) {
super(); super();

View File

@ -7,13 +7,21 @@ export class RenameSceneCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private previousName: string | null = null; private previousName: string | null = null;
constructor( constructor({
private sceneId: string, sceneId,
private newName: string, newName,
) { }: {
sceneId: string;
newName: string;
}) {
super(); super();
this.sceneId = sceneId;
this.newName = newName;
} }
private sceneId: string;
private newName: string;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes(); const scenes = editor.scenes.getScenes();

View File

@ -6,11 +6,11 @@ import {
getFrameTime, getFrameTime,
toggleBookmarkInArray, toggleBookmarkInArray,
} from "@/timeline/bookmarks/index"; } from "@/timeline/bookmarks/index";
import type { MediaTime } from "@/wasm"; import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export class ToggleBookmarkCommand extends Command { export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private frameTime: MediaTime = 0 as MediaTime; private frameTime: MediaTime = ZERO_MEDIA_TIME;
constructor(private time: MediaTime) { constructor(private time: MediaTime) {
super(); super();

View File

@ -11,13 +11,21 @@ import type { MediaTime } from "@/wasm";
export class UpdateBookmarkCommand extends Command { export class UpdateBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
constructor( constructor({
private time: MediaTime, time,
private updates: Partial<Omit<Bookmark, "time">>, updates,
) { }: {
time: MediaTime;
updates: Partial<Omit<Bookmark, "time">>;
}) {
super(); super();
this.time = time;
this.updates = updates;
} }
private time: MediaTime;
private updates: Partial<Omit<Bookmark, "time">>;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();

View File

@ -11,14 +11,22 @@ export class AddTrackCommand extends Command {
private trackId: string; private trackId: string;
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
constructor( constructor({
private type: TrackType, type,
private index?: number, index,
) { }: {
type: TrackType;
index?: number;
}) {
super(); super();
this.type = type;
this.index = index;
this.trackId = generateUUID(); this.trackId = generateUUID();
} }
private type: TrackType;
private index?: number;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks; this.savedState = editor.scenes.getActiveScene().tracks;

View File

@ -3,13 +3,21 @@ import type { SceneTracks } from "@/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
export class TracksSnapshotCommand extends Command { export class TracksSnapshotCommand extends Command {
constructor( constructor({
private before: SceneTracks, before,
private after: SceneTracks, after,
) { }: {
before: SceneTracks;
after: SceneTracks;
}) {
super(); super();
this.before = before;
this.after = after;
} }
private before: SceneTracks;
private after: SceneTracks;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
EditorCore.getInstance().timeline.updateTracks(this.after); EditorCore.getInstance().timeline.updateTracks(this.after);
return undefined; return undefined;

View File

@ -93,7 +93,7 @@ interface AssetsPanelStore {
setMediaViewMode: (mode: MediaViewMode) => void; setMediaViewMode: (mode: MediaViewMode) => void;
mediaSortBy: MediaSortKey; mediaSortBy: MediaSortKey;
mediaSortOrder: MediaSortOrder; mediaSortOrder: MediaSortOrder;
setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void; setMediaSort: (args: { key: MediaSortKey; order: MediaSortOrder }) => void;
} }
export const useAssetsPanelStore = create<AssetsPanelStore>()( export const useAssetsPanelStore = create<AssetsPanelStore>()(
@ -109,7 +109,7 @@ export const useAssetsPanelStore = create<AssetsPanelStore>()(
setMediaViewMode: (mode) => set({ mediaViewMode: mode }), setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
mediaSortBy: "name", mediaSortBy: "name",
mediaSortOrder: "asc", mediaSortOrder: "asc",
setMediaSort: (key, order) => setMediaSort: ({ key, order }) =>
set({ mediaSortBy: key, mediaSortOrder: order }), set({ mediaSortBy: key, mediaSortOrder: order }),
}), }),
{ {

View File

@ -139,9 +139,12 @@ export function MediaView() {
const handleSort = ({ key }: { key: MediaSortKey }) => { const handleSort = ({ key }: { key: MediaSortKey }) => {
if (mediaSortBy === key) { if (mediaSortBy === key) {
setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc"); setMediaSort({
key,
order: mediaSortOrder === "asc" ? "desc" : "asc",
});
} else { } else {
setMediaSort(key, "asc"); setMediaSort({ key, order: "asc" });
} }
}; };

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useState } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { import {
Select, Select,
@ -34,6 +34,10 @@ import type { TCanvasSize } from "@/project/types";
type SettingsView = "project-info" | "background"; type SettingsView = "project-info" | "background";
function isSettingsView(value: string): value is SettingsView {
return value === "project-info" || value === "background";
}
const PRESET_LABELS: Record<string, string> = { const PRESET_LABELS: Record<string, string> = {
"1:1": "1:1", "1:1": "1:1",
"16:9": "16:9", "16:9": "16:9",
@ -73,23 +77,20 @@ function useCanvasDimensionDraft({
value: number; value: number;
onCommit: (value: number) => void; onCommit: (value: number) => void;
}) { }) {
const pendingValueRef = useRef(value); const [pendingValue, setPendingValue] = useState(value);
const syncedValueRef = useRef(value);
if (syncedValueRef.current !== value) {
syncedValueRef.current = value;
pendingValueRef.current = value;
}
return usePropertyDraft({ return usePropertyDraft({
displayValue: formatCanvasDimension({ value }), displayValue: formatCanvasDimension({ value }),
parse: (input) => parseCanvasDimension({ input }), parse: (input) => parseCanvasDimension({ input }),
onStartEditing: () => {
setPendingValue(value);
},
onPreview: (nextValue) => { onPreview: (nextValue) => {
pendingValueRef.current = nextValue; setPendingValue(nextValue);
}, },
onCommit: () => { onCommit: () => {
if (pendingValueRef.current !== value) { if (pendingValue !== value) {
onCommit(pendingValueRef.current); onCommit(pendingValue);
} }
}, },
}); });
@ -212,7 +213,14 @@ export function SettingsView() {
contentClassName="px-0" contentClassName="px-0"
scrollClassName="pt-0" scrollClassName="pt-0"
actions={ actions={
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> <Tabs
value={view}
onValueChange={(value) => {
if (isSettingsView(value)) {
setView(value);
}
}}
>
<TabsList> <TabsList>
<TabsTrigger value="project-info">Project info</TabsTrigger> <TabsTrigger value="project-info">Project info</TabsTrigger>
<TabsTrigger value="background">Background</TabsTrigger> <TabsTrigger value="background">Background</TabsTrigger>

View File

@ -1,4 +1,4 @@
import { useReducer, useRef } from "react"; import { useState } from "react";
import { evaluateMathExpression } from "@/utils/math"; import { evaluateMathExpression } from "@/utils/math";
function looksLikeExpression({ input }: { input: string }): boolean { function looksLikeExpression({ input }: { input: string }): boolean {
@ -14,59 +14,56 @@ export function usePropertyDraft<T>({
parse, parse,
onPreview, onPreview,
onCommit, onCommit,
onStartEditing,
supportsExpressions = true, supportsExpressions = true,
}: { }: {
displayValue: string; displayValue: string;
parse: (input: string) => T | null; parse: (input: string) => T | null;
onPreview: (value: T) => void; onPreview: (value: T) => void;
onCommit: () => void; onCommit: () => void;
onStartEditing?: () => void;
supportsExpressions?: boolean; supportsExpressions?: boolean;
}) { }) {
const [, forceRender] = useReducer( const [isEditing, setIsEditing] = useState(false);
(renderVersion: number) => renderVersion + 1, const [draft, setDraft] = useState("");
0,
);
const isEditing = useRef(false);
const draft = useRef("");
return { return {
displayValue: isEditing.current ? draft.current : sourceDisplay, displayValue: isEditing ? draft : sourceDisplay,
scrubTo: (value: number) => { scrubTo: (value: number) => {
const parsed = parse(String(value)); const parsed = parse(String(value));
if (parsed !== null) onPreview(parsed); if (parsed !== null) onPreview(parsed);
}, },
commitScrub: onCommit, commitScrub: onCommit,
onFocus: () => { onFocus: () => {
isEditing.current = true; setIsEditing(true);
draft.current = sourceDisplay; setDraft(sourceDisplay);
forceRender(); onStartEditing?.();
}, },
onChange: ( onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => { ) => {
draft.current = event.target.value; const nextDraft = event.target.value;
forceRender(); setDraft(nextDraft);
const parsed = parse(event.target.value); const parsed = parse(nextDraft);
if (parsed !== null) { if (parsed !== null) {
onPreview(parsed); onPreview(parsed);
} }
}, },
onBlur: () => { onBlur: (
if ( event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
supportsExpressions && ) => {
looksLikeExpression({ input: draft.current }) const nextDraft = event.target.value;
) { if (supportsExpressions && looksLikeExpression({ input: nextDraft })) {
const evaluated = evaluateMathExpression({ input: draft.current }); const evaluated = evaluateMathExpression({ input: nextDraft });
if (evaluated !== null) { if (evaluated !== null) {
const parsed = parse(String(evaluated)); const parsed = parse(String(evaluated));
if (parsed !== null) onPreview(parsed); if (parsed !== null) onPreview(parsed);
} }
} }
onCommit(); onCommit();
isEditing.current = false; setIsEditing(false);
draft.current = ""; setDraft("");
forceRender();
}, },
}; };
} }

View File

@ -71,7 +71,12 @@ export function PropertiesPanel() {
<Button <Button
variant={tab.id === activeTab.id ? "secondary" : "ghost"} variant={tab.id === activeTab.id ? "secondary" : "ghost"}
size="icon" size="icon"
onClick={() => setActiveTab(element.type, tab.id)} onClick={() =>
setActiveTab({
elementType: element.type,
tabId: tab.id,
})
}
aria-label={tab.label} aria-label={tab.label}
className={cn( className={cn(
"shrink-0", "shrink-0",

View File

@ -2,17 +2,18 @@ import { create } from "zustand";
interface PropertiesState { interface PropertiesState {
activeTabPerType: Record<string, string>; activeTabPerType: Record<string, string>;
setActiveTab: (elementType: string, tabId: string) => void; setActiveTab: (args: { elementType: string; tabId: string }) => void;
isTransformScaleLocked: boolean; isTransformScaleLocked: boolean;
setTransformScaleLocked: (locked: boolean) => void; setTransformScaleLocked: (args: { locked: boolean }) => void;
} }
export const usePropertiesStore = create<PropertiesState>()((set) => ({ export const usePropertiesStore = create<PropertiesState>()((set) => ({
activeTabPerType: {}, activeTabPerType: {},
setActiveTab: (elementType, tabId) => setActiveTab: ({ elementType, tabId }) =>
set((state) => ({ set((state) => ({
activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId }, activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
})), })),
isTransformScaleLocked: false, isTransformScaleLocked: false,
setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }), setTransformScaleLocked: ({ locked }) =>
set({ isTransformScaleLocked: locked }),
})); }));

View File

@ -39,7 +39,7 @@ export class EditorCore {
this.project = new ProjectManager(this); this.project = new ProjectManager(this);
this.media = new MediaManager(this); this.media = new MediaManager(this);
this.renderer = new RendererManager(this); this.renderer = new RendererManager(this);
this.save = new SaveManager(this); this.save = new SaveManager({ editor: this });
this.audio = new AudioManager(this); this.audio = new AudioManager(this);
this.selection = new SelectionManager(this); this.selection = new SelectionManager(this);
this.clipboard = new ClipboardManager(this); this.clipboard = new ClipboardManager(this);

View File

@ -68,9 +68,17 @@ export class MediaManager {
const command = const command =
uniqueIds.length === 1 uniqueIds.length === 1
? new RemoveMediaAssetCommand(projectId, uniqueIds[0]) ? new RemoveMediaAssetCommand({
projectId,
assetId: uniqueIds[0],
})
: new BatchCommand( : new BatchCommand(
uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)), uniqueIds.map((id) =>
new RemoveMediaAssetCommand({
projectId,
assetId: id,
}),
),
); );
this.editor.command.execute({ command }); this.editor.command.execute({ command });

View File

@ -12,13 +12,18 @@ export class SaveManager {
private saveTimer: ReturnType<typeof setTimeout> | null = null; private saveTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeHandlers: Array<() => void> = []; private unsubscribeHandlers: Array<() => void> = [];
constructor( constructor({
private editor: EditorCore, editor,
{ debounceMs = 800 }: SaveManagerOptions = {}, debounceMs = 800,
) { }: {
editor: EditorCore;
} & SaveManagerOptions) {
this.editor = editor;
this.debounceMs = debounceMs; this.debounceMs = debounceMs;
} }
private editor: EditorCore;
start(): void { start(): void {
if (this.unsubscribeHandlers.length > 0) return; if (this.unsubscribeHandlers.length > 0) return;

View File

@ -41,7 +41,7 @@ export class ScenesManager {
throw new Error("No active project"); throw new Error("No active project");
} }
const command = new CreateSceneCommand(name, isMain); const command = new CreateSceneCommand({ name, isMain });
this.editor.command.execute({ command }); this.editor.command.execute({ command });
return command.getSceneId(); return command.getSceneId();
} }
@ -77,7 +77,10 @@ export class ScenesManager {
throw new Error("No active project"); throw new Error("No active project");
} }
const command = new RenameSceneCommand(sceneId, name); const command = new RenameSceneCommand({
sceneId,
newName: name,
});
this.editor.command.execute({ command }); this.editor.command.execute({ command });
} }
@ -138,7 +141,7 @@ export class ScenesManager {
time: MediaTime; time: MediaTime;
updates: Partial<Omit<Bookmark, "time">>; updates: Partial<Omit<Bookmark, "time">>;
}): Promise<void> { }): Promise<void> {
const command = new UpdateBookmarkCommand(time, updates); const command = new UpdateBookmarkCommand({ time, updates });
this.editor.command.execute({ command }); this.editor.command.execute({ command });
} }
@ -149,7 +152,7 @@ export class ScenesManager {
fromTime: MediaTime; fromTime: MediaTime;
toTime: MediaTime; toTime: MediaTime;
}): Promise<void> { }): Promise<void> {
const command = new MoveBookmarkCommand(fromTime, toTime); const command = new MoveBookmarkCommand({ fromTime, toTime });
this.editor.command.execute({ command }); this.editor.command.execute({ command });
} }

View File

@ -73,7 +73,7 @@ export class TimelineManager {
constructor(private editor: EditorCore) {} constructor(private editor: EditorCore) {}
addTrack({ type, index }: { type: TrackType; index?: number }): string { 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 }); this.editor.command.execute({ command });
return command.getTrackId(); return command.getTrackId();
} }
@ -751,7 +751,10 @@ export class TimelineManager {
} }
const afterTracks = const afterTracks =
this.previewTracks ?? this.applyPreviewOverlay(committedTracks); 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.editor.command.push({ command });
this.previewOverlay.clear(); this.previewOverlay.clear();
this.previewTracks = null; this.previewTracks = null;

View File

@ -14,7 +14,7 @@ export type PanelId = keyof PanelSizes;
interface PanelState { interface PanelState {
panels: PanelSizes; panels: PanelSizes;
setPanel: (panel: PanelId, size: number) => void; setPanel: (args: { panel: PanelId; size: number }) => void;
setPanels: (sizes: Partial<PanelSizes>) => void; setPanels: (sizes: Partial<PanelSizes>) => void;
resetPanels: () => void; resetPanels: () => void;
} }
@ -23,7 +23,7 @@ export const usePanelStore = create<PanelState>()(
persist( persist(
(set) => ({ (set) => ({
...PANEL_CONFIG, ...PANEL_CONFIG,
setPanel: (panel, size) => setPanel: ({ panel, size }) =>
set((state) => ({ set((state) => ({
panels: { panels: {
...state.panels, ...state.panels,

View File

@ -1,7 +1,15 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { EditorCore } from "@/core"; 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 (Object.is(a, b)) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false; if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
@ -16,10 +24,7 @@ export function useEditor<T>(
selector?: (editor: EditorCore) => T, selector?: (editor: EditorCore) => T,
): EditorCore | T { ): EditorCore | T {
const editor = useMemo(() => EditorCore.getInstance(), []); const editor = useMemo(() => EditorCore.getInstance(), []);
const selectorRef = useRef(selector); const snapshotCacheRef = useRef<T | typeof SNAPSHOT_UNSET>(SNAPSHOT_UNSET);
selectorRef.current = selector;
const snapshotCacheRef = useRef<unknown>(undefined);
const subscribeAll = useCallback( const subscribeAll = useCallback(
(onChange: () => void) => { (onChange: () => void) => {
@ -43,18 +48,29 @@ export function useEditor<T>(
[editor], [editor],
); );
const getSnapshot = useCallback(() => { const getSnapshot = useCallback((): EditorCore | T => {
const next = selectorRef.current ? selectorRef.current(editor) : editor; if (!selector) {
if (isShallowEqual(snapshotCacheRef.current, next)) { return editor;
}
const next = selector(editor);
if (
snapshotCacheRef.current !== SNAPSHOT_UNSET &&
isShallowEqual({
a: snapshotCacheRef.current,
b: next,
})
) {
return snapshotCacheRef.current; return snapshotCacheRef.current;
} }
snapshotCacheRef.current = next; snapshotCacheRef.current = next;
return next; return next;
}, [editor]); }, [editor, selector]);
return useSyncExternalStore( return useSyncExternalStore(
selector ? subscribeAll : subscribeNone, selector ? subscribeAll : subscribeNone,
getSnapshot, getSnapshot,
getSnapshot, getSnapshot,
) as EditorCore | T; );
} }

View File

@ -8,6 +8,9 @@ export function registerDefaultEffects(): void {
if (effectsRegistry.has(definition.type)) { if (effectsRegistry.has(definition.type)) {
continue; continue;
} }
effectsRegistry.register(definition.type, definition); effectsRegistry.register({
key: definition.type,
definition,
});
} }
} }

View File

@ -22,7 +22,13 @@ export function frameRateToFloat(rate: FrameRate): number {
return rate.numerator / rate.denominator; 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; 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 ARBITRARY_DENOMINATOR = 1_000_000;
const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR); const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR);
const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR); const divisor = gcd({
left: scaledNumerator,
right: ARBITRARY_DENOMINATOR,
});
return { return {
numerator: scaledNumerator / divisor, numerator: scaledNumerator / divisor,
denominator: ARBITRARY_DENOMINATOR / 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 a = Math.abs(left);
let b = Math.abs(right); let b = Math.abs(right);
while (b !== 0) { while (b !== 0) {

View File

@ -16,7 +16,10 @@ export function registerDefaultGraphics(): void {
if (graphicsRegistry.has(definition.id)) { if (graphicsRegistry.has(definition.id)) {
continue; continue;
} }
graphicsRegistry.register(definition.id, definition); graphicsRegistry.register({
key: definition.id,
definition,
});
} }
} }

View File

@ -60,10 +60,13 @@ export function buildDefaultGraphicInstance({
}; };
} }
export function resolveGraphicParams( export function resolveGraphicParams({
definition: GraphicDefinition, definition,
params?: ParamValues, params,
): ParamValues { }: {
definition: GraphicDefinition;
params?: ParamValues;
}): ParamValues {
return { return {
...buildDefaultParamValues(definition.params), ...buildDefaultParamValues(definition.params),
...(params ?? {}), ...(params ?? {}),
@ -85,7 +88,10 @@ export function resolveGraphicElementParamsAtTime({
definitionId: element.definitionId, definitionId: element.definitionId,
}); });
return resolveGraphicParamsAtTime({ return resolveGraphicParamsAtTime({
params: resolveGraphicParams(definition, element.params), params: resolveGraphicParams({
definition,
params: element.params,
}),
definitions: definition.params, definitions: definition.params,
animations: element.animations, animations: element.animations,
localTime, localTime,
@ -102,7 +108,7 @@ export function buildGraphicPreviewUrl({
size?: number; size?: number;
}): string { }): string {
const definition = getGraphicDefinition({ definitionId }); const definition = getGraphicDefinition({ definitionId });
const resolvedParams = resolveGraphicParams(definition, params); const resolvedParams = resolveGraphicParams({ definition, params });
const cacheKey = JSON.stringify({ definitionId, resolvedParams, size }); const cacheKey = JSON.stringify({ definitionId, resolvedParams, size });
const cachedUrl = graphicPreviewUrlCache.get(cacheKey); const cachedUrl = graphicPreviewUrlCache.get(cacheKey);
if (cachedUrl) { if (cachedUrl) {

View File

@ -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;
}

View File

@ -1,4 +1,5 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useCommittedRef } from "@/hooks/use-committed-ref";
type FocusLockCursor = "text" | "default" | "pointer" | "crosshair"; type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
@ -37,8 +38,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({
allowSelector?: string; allowSelector?: string;
}) { }) {
const containerRef = useRef<T>(null); const containerRef = useRef<T>(null);
const onDismissRef = useRef(onDismiss); const onDismissRef = useCommittedRef(onDismiss);
onDismissRef.current = onDismiss;
useEffect(() => { useEffect(() => {
if (!isActive) return; if (!isActive) return;
@ -53,10 +53,13 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({
const handleOutsidePointerDown = (event: PointerEvent) => { const handleOutsidePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return; 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 =
const isAllowedTarget = allowSelector && target?.closest(allowSelector); allowSelector &&
target instanceof Element &&
target.closest(allowSelector);
if (isAllowedTarget) return; if (isAllowedTarget) return;
onDismissRef.current(); onDismissRef.current();
@ -73,7 +76,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({
container.removeAttribute(DATA_ATTR); container.removeAttribute(DATA_ATTR);
focusLockStyle.remove(); focusLockStyle.remove();
}; };
}, [isActive, cursor, allowSelector]); }, [isActive, cursor, allowSelector, onDismissRef]);
return { containerRef }; return { containerRef };
} }

View File

@ -330,13 +330,27 @@ function clampUnit(value: number): number {
return Math.min(1, Math.max(0, value)); 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 dx = a.x - b.x;
const dy = a.y - b.y; const dy = a.y - b.y;
return dx * dx + dy * dy; 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 { return {
x: a.x + (b.x - a.x) * t, x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t, y: a.y + (b.y - a.y) * t,
@ -483,7 +497,10 @@ export function findClosestPointOnCustomMaskSegment({
const sampleCount = 24; const sampleCount = 24;
let bestT = 0; let bestT = 0;
let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start); let bestDistanceSquared = getDistanceSquared({
a: canvasPoint,
b: segment.start,
});
for (let step = 0; step <= sampleCount; step++) { for (let step = 0; step <= sampleCount; step++) {
const t = step / sampleCount; const t = step / sampleCount;
@ -494,7 +511,7 @@ export function findClosestPointOnCustomMaskSegment({
p3: segment.end, p3: segment.end,
t, t,
}); });
const distanceSquared = getDistanceSquared(canvasPoint, point); const distanceSquared = getDistanceSquared({ a: canvasPoint, b: point });
if (distanceSquared < bestDistanceSquared) { if (distanceSquared < bestDistanceSquared) {
bestDistanceSquared = distanceSquared; bestDistanceSquared = distanceSquared;
bestT = t; bestT = t;
@ -516,7 +533,10 @@ export function findClosestPointOnCustomMaskSegment({
}), }),
})); }));
for (const candidate of candidates) { for (const candidate of candidates) {
const distanceSquared = getDistanceSquared(canvasPoint, candidate.point); const distanceSquared = getDistanceSquared({
a: canvasPoint,
b: candidate.point,
});
if (distanceSquared < bestDistanceSquared) { if (distanceSquared < bestDistanceSquared) {
bestDistanceSquared = distanceSquared; bestDistanceSquared = distanceSquared;
bestT = candidate.t; bestT = candidate.t;
@ -573,12 +593,12 @@ export function insertPointIntoCustomMaskSegment({
y: endPoint.y + endPoint.inY, y: endPoint.y + endPoint.inY,
}; };
const p3 = { x: endPoint.x, y: endPoint.y }; const p3 = { x: endPoint.x, y: endPoint.y };
const p01 = lerpPoint(p0, p1, clampedT); const p01 = lerpPoint({ a: p0, b: p1, t: clampedT });
const p12 = lerpPoint(p1, p2, clampedT); const p12 = lerpPoint({ a: p1, b: p2, t: clampedT });
const p23 = lerpPoint(p2, p3, clampedT); const p23 = lerpPoint({ a: p2, b: p3, t: clampedT });
const p012 = lerpPoint(p01, p12, clampedT); const p012 = lerpPoint({ a: p01, b: p12, t: clampedT });
const p123 = lerpPoint(p12, p23, clampedT); const p123 = lerpPoint({ a: p12, b: p23, t: clampedT });
const splitPoint = lerpPoint(p012, p123, clampedT); const splitPoint = lerpPoint({ a: p012, b: p123, t: clampedT });
const nextPoints = [...points]; const nextPoints = [...points];
nextPoints[indices.startIndex] = { nextPoints[indices.startIndex] = {

View File

@ -53,10 +53,13 @@ function splitLineGeometry({
return { normalX, normalY, lineX, lineY }; return { normalX, normalY, lineX, lineY };
} }
function pointsEqual( function pointsEqual({
a: { x: number; y: number }, a,
b: { x: number; y: number }, b,
): boolean { }: {
a: { x: number; y: number };
b: { x: number; y: number };
}): boolean {
return ( return (
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON && Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
Math.abs(a.y - b.y) <= INTERSECTION_EPSILON Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
@ -101,7 +104,7 @@ export function getSplitMaskStrokeSegment({
y2, y2,
}); });
if (!hit || intersections.some((point) => pointsEqual(point, hit))) { if (!hit || intersections.some((point) => pointsEqual({ a: point, b: hit }))) {
continue; continue;
} }
@ -307,13 +310,18 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
[0, height, 0, 0], [0, height, 0, 0],
]; ];
const isInsideHalfPlane = (x: number, y: number) => const isInsideHalfPlane = ({
halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; x,
y,
}: {
x: number;
y: number;
}) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
const vertices: [number, number][] = []; const vertices: [number, number][] = [];
for (const [x1, y1, x2, y2] of edges) { for (const [x1, y1, x2, y2] of edges) {
const isVertex1Inside = isInsideHalfPlane(x1, y1); const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 });
const isVertex2Inside = isInsideHalfPlane(x2, y2); const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 });
if (isVertex1Inside && isVertex2Inside) { if (isVertex1Inside && isVertex2Inside) {
vertices.push([x2, y2]); vertices.push([x2, y2]);

View File

@ -115,7 +115,10 @@ export class MasksRegistry extends DefinitionRegistry<
}, },
icon, icon,
}; };
this.register(definition.type, withBaseParams); this.register({
key: definition.type,
definition: withBaseParams,
});
} }
} }

View File

@ -68,10 +68,10 @@ export function usePasteMedia() {
const startTime = editor.playback.getCurrentTime(); const startTime = editor.playback.getCurrentTime();
for (const asset of processedAssets) { for (const asset of processedAssets) {
const addMediaCmd = new AddMediaAssetCommand( const addMediaCmd = new AddMediaAssetCommand({
activeProject.metadata.id, projectId: activeProject.metadata.id,
asset, asset,
); });
const assetId = addMediaCmd.getAssetId(); const assetId = addMediaCmd.getAssetId();
const duration = const duration =
asset.duration != null asset.duration != null

View File

@ -18,7 +18,13 @@ export class DefinitionRegistry<TKey extends string, TDefinition> {
this.entityName = entityName; this.entityName = entityName;
} }
register(key: TKey, definition: TDefinition): void { register({
key,
definition,
}: {
key: TKey;
definition: TDefinition;
}): void {
this.definitions.set(key, definition); this.definitions.set(key, definition);
} }

View File

@ -13,12 +13,12 @@ import { toast } from "sonner";
export function PreviewContextMenu({ export function PreviewContextMenu({
onToggleFullscreen, onToggleFullscreen,
containerRef, container,
overlayControls, overlayControls,
onOverlayVisibilityChange, onOverlayVisibilityChange,
}: { }: {
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
containerRef: React.RefObject<HTMLElement | null>; container: HTMLElement | null;
overlayControls: PreviewOverlayControl[]; overlayControls: PreviewOverlayControl[];
onOverlayVisibilityChange: (params: { onOverlayVisibilityChange: (params: {
overlayId: string; overlayId: string;
@ -51,7 +51,7 @@ export function PreviewContextMenu({
}; };
return ( return (
<ContextMenuContent className="w-56" container={containerRef.current}> <ContextMenuContent className="w-56" container={container}>
<ContextMenuItem onClick={viewport.fitToScreen} inset> <ContextMenuItem onClick={viewport.fitToScreen} inset>
Fit to screen Fit to screen
</ContextMenuItem> </ContextMenuItem>

View File

@ -1,6 +1,6 @@
"use client"; "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 useDeepCompareEffect from "use-deep-compare-effect";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useRafLoop } from "@/hooks/use-raf-loop"; import { useRafLoop } from "@/hooks/use-raf-loop";
@ -68,15 +68,20 @@ export function PreviewPanel({
}) => void; }) => void;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const { toggleFullscreen } = useFullscreen({ containerRef }); const { toggleFullscreen } = useFullscreen({ containerRef });
const handleContainerRef = useCallback((node: HTMLDivElement | null) => {
containerRef.current = node;
setContainer(node);
}, []);
return ( return (
<div <div
ref={containerRef} ref={handleContainerRef}
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border" className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
> >
<PreviewCanvas <PreviewCanvas
containerRef={containerRef} container={container}
onToggleFullscreen={toggleFullscreen} onToggleFullscreen={toggleFullscreen}
overlayControls={overlayControls} overlayControls={overlayControls}
overlayInstances={overlayInstances} overlayInstances={overlayInstances}
@ -117,13 +122,13 @@ function RenderTreeController() {
} }
function PreviewCanvas({ function PreviewCanvas({
containerRef, container,
onToggleFullscreen, onToggleFullscreen,
overlayControls, overlayControls,
overlayInstances, overlayInstances,
onOverlayVisibilityChange, onOverlayVisibilityChange,
}: { }: {
containerRef: React.RefObject<HTMLElement | null>; container: HTMLElement | null;
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
overlayControls: PreviewOverlayControl[]; overlayControls: PreviewOverlayControl[];
overlayInstances: PreviewOverlayInstance[]; overlayInstances: PreviewOverlayInstance[];
@ -149,6 +154,7 @@ function PreviewCanvas({
viewportRef, viewportRef,
viewportWidth: viewportSize.width, viewportWidth: viewportSize.width,
}); });
const { canPan, panByScreenDelta, scaleZoom } = viewport;
const renderer = useMemo(() => { const renderer = useMemo(() => {
return new CanvasRenderer({ return new CanvasRenderer({
@ -240,7 +246,7 @@ function PreviewCanvas({
Math.min(Math.abs(pendingZoomDelta), 30); Math.min(Math.abs(pendingZoomDelta), 30);
const zoomFactor = Math.exp(-cappedDelta / 300); const zoomFactor = Math.exp(-cappedDelta / 300);
viewport.scaleZoom({ factor: zoomFactor }); scaleZoom({ factor: zoomFactor });
pendingZoomDelta = 0; pendingZoomDelta = 0;
zoomRafId = null; zoomRafId = null;
}); });
@ -249,7 +255,7 @@ function PreviewCanvas({
return; return;
} }
if (!viewport.canPan) { if (!canPan) {
return; return;
} }
@ -263,7 +269,7 @@ function PreviewCanvas({
if (panRafId === null) { if (panRafId === null) {
panRafId = requestAnimationFrame(() => { panRafId = requestAnimationFrame(() => {
viewport.panByScreenDelta({ panByScreenDelta({
deltaX: pendingPanDeltaX, deltaX: pendingPanDeltaX,
deltaY: pendingPanDeltaY, deltaY: pendingPanDeltaY,
}); });
@ -290,7 +296,7 @@ function PreviewCanvas({
cancelAnimationFrame(panRafId); cancelAnimationFrame(panRafId);
} }
}; };
}, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]); }, [canPan, panByScreenDelta, scaleZoom]);
return ( return (
<PreviewViewportProvider value={viewport}> <PreviewViewportProvider value={viewport}>
@ -329,7 +335,7 @@ function PreviewCanvas({
</ContextMenuTrigger> </ContextMenuTrigger>
<PreviewContextMenu <PreviewContextMenu
onToggleFullscreen={onToggleFullscreen} onToggleFullscreen={onToggleFullscreen}
containerRef={containerRef} container={container}
overlayControls={overlayControls} overlayControls={overlayControls}
onOverlayVisibilityChange={onOverlayVisibilityChange} onOverlayVisibilityChange={onOverlayVisibilityChange}
/> />

View File

@ -9,6 +9,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useCommittedRef } from "@/hooks/use-committed-ref";
import { import {
canvasToOverlay, canvasToOverlay,
getDisplayScale, getDisplayScale,
@ -219,8 +220,7 @@ export function usePreviewViewportState({
})); }));
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
const panSessionRef = useRef<PanSession | null>(null); const panSessionRef = useRef<PanSession | null>(null);
const centerRef = useRef(center); const centerRef = useCommittedRef(center);
centerRef.current = center;
const fitScale = useMemo( const fitScale = useMemo(
() => () =>
@ -409,10 +409,10 @@ export function usePreviewViewportState({
pointerId: event.pointerId, pointerId: event.pointerId,
}; };
setIsPanning(true); setIsPanning(true);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
return true; return true;
}, },
[zoom], [centerRef, zoom],
); );
const handlePanPointerMove = useCallback( const handlePanPointerMove = useCallback(
@ -451,13 +451,9 @@ export function usePreviewViewportState({
} }
if ( if (
(event.currentTarget as HTMLElement).hasPointerCapture( event.currentTarget.hasPointerCapture(panSession.pointerId)
panSession.pointerId,
)
) { ) {
(event.currentTarget as HTMLElement).releasePointerCapture( event.currentTarget.releasePointerCapture(panSession.pointerId);
panSession.pointerId,
);
} }
panSessionRef.current = null; panSessionRef.current = null;

View File

@ -120,7 +120,6 @@ export function TextEditOverlay({
transformOrigin: "center center", transformOrigin: "center center",
}} }}
> >
{/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
<div <div
ref={divRef} ref={divRef}
contentEditable contentEditable

View File

@ -1,5 +1,6 @@
import { useEffect, useReducer, useRef } from "react"; import { useEffect, useReducer, useState } from "react";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useShiftKey } from "@/hooks/use-shift-key"; import { useShiftKey } from "@/hooks/use-shift-key";
import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { usePreviewViewport } from "@/preview/components/preview-viewport";
import type { SnapLine } from "@/preview/preview-snap"; import type { SnapLine } from "@/preview/preview-snap";
@ -59,17 +60,10 @@ export function usePreviewInteraction({
onSnapLinesChange, onSnapLinesChange,
}, },
}; };
const depsRef = useCommittedRef(deps) as PreviewInteractionDepsRef;
const depsRef = useRef<PreviewInteractionDeps>(deps); const [controller] = useState(
depsRef.current = deps; () => new PreviewInteractionController({ depsRef }),
);
const controllerRef = useRef<PreviewInteractionController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new PreviewInteractionController({
depsRef: depsRef as PreviewInteractionDepsRef,
});
}
const controller = controllerRef.current;
const [, rerender] = useReducer((n: number) => n + 1, 0); const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect( useEffect(

View File

@ -1,7 +1,8 @@
import { useEffect, useReducer, useRef } from "react"; import { useEffect, useReducer, useState } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { usePreviewViewport } from "@/preview/components/preview-viewport";
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction"; import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useShiftKey } from "@/hooks/use-shift-key"; import { useShiftKey } from "@/hooks/use-shift-key";
import { registerCanceller } from "@/editor/cancel-interaction"; import { registerCanceller } from "@/editor/cancel-interaction";
import { import {
@ -48,15 +49,10 @@ export function useTransformHandles({
onSnapLinesChange, onSnapLinesChange,
}, },
}; };
const depsRef = useCommittedRef(deps);
const depsRef = useRef<TransformHandleDeps>(deps); const [controller] = useState(
depsRef.current = deps; () => new TransformHandleController({ depsRef }),
);
const controllerRef = useRef<TransformHandleController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new TransformHandleController({ depsRef });
}
const controller = controllerRef.current;
const [, rerender] = useReducer((n: number) => n + 1, 0); const [, rerender] = useReducer((n: number) => n + 1, 0);
useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => controller.subscribe(rerender), [controller]);

View File

@ -1,231 +0,0 @@
import { useEditor } from "@/editor/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import { OcCheckerboardIcon } from "@/components/icons";
import { Fragment, useRef } from "react";
import { useMenuPreview } from "@/editor/use-menu-preview";
import {
Section,
SectionContent,
SectionField,
SectionHeader,
SectionTitle,
} from "@/components/section";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BlendMode } from "@/rendering";
import type { ElementType } from "@/timeline";
import type { ElementAnimations } from "@/animation/types";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/animation/values";
import { DEFAULTS } from "@/timeline/defaults";
import { isPropertyAtDefault } from "./transform-tab";
import type { MediaTime } from "@/wasm";
type BlendingElement = {
id: string;
opacity: number;
type: ElementType;
blendMode?: BlendMode;
startTime: MediaTime;
duration: MediaTime;
animations?: ElementAnimations;
};
const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [
[{ value: "normal", label: "Normal" }],
[
{ value: "darken", label: "Darken" },
{ value: "multiply", label: "Multiply" },
{ value: "color-burn", label: "Color Burn" },
],
[
{ value: "lighten", label: "Lighten" },
{ value: "screen", label: "Screen" },
{ value: "plus-lighter", label: "Plus Lighter" },
{ value: "color-dodge", label: "Color Dodge" },
],
[
{ value: "overlay", label: "Overlay" },
{ value: "soft-light", label: "Soft Light" },
{ value: "hard-light", label: "Hard Light" },
],
[
{ value: "difference", label: "Difference" },
{ value: "exclusion", label: "Exclusion" },
],
[
{ value: "hue", label: "Hue" },
{ value: "saturation", label: "Saturation" },
{ value: "color", label: "Color" },
{ value: "luminosity", label: "Luminosity" },
],
];
export function BlendingTab({
element,
trackId,
}: {
element: BlendingElement;
trackId: string;
}) {
const editor = useEditor();
const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
const committedBlendModeRef = useRef(blendMode);
if (!isPreviewActive) {
committedBlendModeRef.current = blendMode;
}
const {
onPointerLeave,
onOpenChange: handleBlendModeOpenChange,
markCommitted,
} = useMenuPreview();
const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } },
],
});
const commitBlendMode = (value: BlendMode) => {
if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview();
} else {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
patch: { blendMode: value },
},
],
});
}
markCommitted();
};
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const resolvedOpacity = resolveOpacityAtTime({
baseOpacity: element.opacity,
animations: element.animations,
localTime,
});
const opacity = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "opacity",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedOpacity * 100).toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
valueAtPlayhead: resolvedOpacity,
step: 0.01,
buildBaseUpdates: ({ value }) => ({ opacity: value }),
});
return (
<Section collapsible sectionKey={`${element.id}:blending`}>
<SectionHeader>
<SectionTitle>Blending</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
<SectionField
label="Opacity"
className="w-1/2"
beforeLabel={
<KeyframeToggle
isActive={opacity.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle opacity keyframe"
onToggle={opacity.toggleKeyframe}
/>
}
>
<NumberField
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() =>
opacity.commitValue({ value: DEFAULTS.element.opacity })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOpacity,
staticValue: element.opacity,
defaultValue: DEFAULTS.element.opacity,
})}
dragSensitivity="slow"
/>
</SectionField>
<SectionField label="Blend mode" className="w-1/2">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36" onPointerLeave={onPointerLeave}>
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode({ value: option.value })
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</SectionField>
</div>
</SectionContent>
</Section>
);
}

View File

@ -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>
);
}

View File

@ -4,15 +4,40 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { import type {
BoxSelectionSnapshot, BoxSelectionSnapshot,
ResolveIntersections, ResolveIntersections,
SelectionBoxBounds,
} from "@/selection/types"; } from "@/selection/types";
interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> { interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> {
startPos: { x: number; y: number }; startPos: { x: number; y: number };
currentPos: { x: number; y: number }; currentPos: { x: number; y: number };
bounds: SelectionBoxBounds | null;
isActive: boolean; isActive: boolean;
isAdditive: 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>({ export function useBoxSelect<TId>({
containerRef, containerRef,
resolveIntersections, resolveIntersections,
@ -40,33 +65,43 @@ export function useBoxSelect<TId>({
const [selectionBox, setSelectionBox] = const [selectionBox, setSelectionBox] =
useState<SelectionBoxState<TId> | null>(null); useState<SelectionBoxState<TId> | null>(null);
const justFinishedSelectingRef = useRef(false); const justFinishedSelectingRef = useRef(false);
const shouldStartSelectionCheck = shouldStartSelection ?? (() => true);
const getIsAdditiveSelectionCheck =
getIsAdditiveSelection ??
((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey);
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
(event: React.MouseEvent<Element>) => { (event: React.MouseEvent<Element>) => {
const canStartSelection = shouldStartSelectionCheck(event); const canStartSelection = shouldStartSelection
? shouldStartSelection(event)
: true;
if (!isEnabled || event.button !== 0 || !canStartSelection) { if (!isEnabled || event.button !== 0 || !canStartSelection) {
return; return;
} }
const startPos = { x: event.clientX, y: event.clientY };
const container = containerRef.current;
setSelectionBox({ setSelectionBox({
startPos: { x: event.clientX, y: event.clientY }, startPos,
currentPos: { x: event.clientX, y: event.clientY }, currentPos: startPos,
bounds: container
? getSelectionBoxBounds({
container,
startPos,
currentPos: startPos,
})
: null,
isActive: false, isActive: false,
isAdditive: getIsAdditiveSelectionCheck(event), isAdditive: getIsAdditiveSelection
? getIsAdditiveSelection(event)
: event.ctrlKey || event.metaKey,
initialSelectedIds: selectedIds, initialSelectedIds: selectedIds,
initialAnchorId: anchorId, initialAnchorId: anchorId,
}); });
}, },
[ [
anchorId, anchorId,
getIsAdditiveSelectionCheck, containerRef,
getIsAdditiveSelection,
isEnabled, isEnabled,
selectedIds, selectedIds,
shouldStartSelectionCheck, shouldStartSelection,
], ],
); );
@ -98,11 +133,20 @@ export function useBoxSelect<TId>({
} }
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
const currentPos = { x: clientX, y: clientY };
const deltaX = Math.abs(clientX - selectionBox.startPos.x); const deltaX = Math.abs(clientX - selectionBox.startPos.x);
const deltaY = Math.abs(clientY - selectionBox.startPos.y); const deltaY = Math.abs(clientY - selectionBox.startPos.y);
const container = containerRef.current;
const nextSelectionBox = { const nextSelectionBox = {
...selectionBox, ...selectionBox,
currentPos: { x: clientX, y: clientY }, currentPos,
bounds: container
? getSelectionBoxBounds({
container,
startPos: selectionBox.startPos,
currentPos,
})
: null,
isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive, isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive,
}; };
@ -133,26 +177,26 @@ export function useBoxSelect<TId>({
window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("mouseup", handleMouseUp);
}; };
}, [selectionBox, updateSelection]); }, [containerRef, selectionBox, updateSelection]);
useEffect(() => { useEffect(() => {
if (!selectionBox) { if (!selectionBox) {
return; return;
} }
const container = containerRef.current;
const previousBodyUserSelect = document.body.style.userSelect; const previousBodyUserSelect = document.body.style.userSelect;
const previousContainerUserSelect = const previousContainerUserSelect = container?.style.userSelect ?? "";
containerRef.current?.style.userSelect ?? "";
document.body.style.userSelect = "none"; document.body.style.userSelect = "none";
if (containerRef.current) { if (container) {
containerRef.current.style.userSelect = "none"; container.style.userSelect = "none";
} }
return () => { return () => {
document.body.style.userSelect = previousBodyUserSelect; document.body.style.userSelect = previousBodyUserSelect;
if (containerRef.current) { if (container) {
containerRef.current.style.userSelect = previousContainerUserSelect; container.style.userSelect = previousContainerUserSelect;
} }
}; };
}, [containerRef, selectionBox]); }, [containerRef, selectionBox]);
@ -162,7 +206,10 @@ export function useBoxSelect<TId>({
}, []); }, []);
return { return {
selectionBox, selectionBox:
selectionBox?.isActive && selectionBox.bounds
? { bounds: selectionBox.bounds }
: null,
handleMouseDown, handleMouseDown,
isSelecting: selectionBox?.isActive ?? false, isSelecting: selectionBox?.isActive ?? false,
shouldIgnoreClick, shouldIgnoreClick,

View File

@ -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 { useSelectionContext } from "@/selection/context";
import { activateScope, type ScopeEntry } from "@/selection/scope"; import { activateScope, type ScopeEntry } from "@/selection/scope";
export function useSelectionScope() { export function useSelectionScope() {
const { selectedIds, clearSelection } = useSelectionContext(); const { selectedIds, clearSelection } = useSelectionContext();
const hasSelectionRef = useRef(selectedIds.length > 0);
const clearSelectionRef = useRef(clearSelection);
const hasSelection = selectedIds.length > 0; const hasSelection = selectedIds.length > 0;
const hasSelectionRef = useCommittedRef(hasSelection);
hasSelectionRef.current = hasSelection; const clearSelectionRef = useCommittedRef(clearSelection);
clearSelectionRef.current = clearSelection; const [entry] = useState<ScopeEntry>(() => ({
const entryRef = useRef<ScopeEntry>({
hasSelection: () => hasSelectionRef.current, hasSelection: () => hasSelectionRef.current,
clear: () => { clear: () => {
clearSelectionRef.current(); clearSelectionRef.current();
}, },
}); }));
useEffect(() => { useEffect(() => {
if (!hasSelection) { if (!hasSelection) {
return; return;
} }
return activateScope({ entry: entryRef.current }); return activateScope({ entry });
}, [hasSelection]); }, [entry, hasSelection]);
} }

View File

@ -6,7 +6,13 @@ import { SELECTABLE_ITEM_ATTRIBUTE } from "@/selection/attributes";
import type { SelectableItemProps } from "@/selection/types"; import type { SelectableItemProps } from "@/selection/types";
import { cn } from "@/utils/ui"; 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") { if (typeof ref === "function") {
ref(value); ref(value);
return; return;
@ -55,7 +61,7 @@ export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>(
const handleRef = useCallback( const handleRef = useCallback(
(element: HTMLDivElement | null) => { (element: HTMLDivElement | null) => {
registerItem(id, element); registerItem(id, element);
setForwardedRef(forwardedRef, element); setForwardedRef({ ref: forwardedRef, value: element });
}, },
[forwardedRef, id, registerItem], [forwardedRef, id, registerItem],
); );

View File

@ -167,14 +167,15 @@ export function SelectableSurface({
[], [],
); );
const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({ const { selectionBox, handleMouseDown, isSelecting, shouldIgnoreClick } =
containerRef, useBoxSelect({
resolveIntersections, containerRef,
selectedIds: selectionState.selectedIds, resolveIntersections,
anchorId: selectionState.anchorId, selectedIds: selectionState.selectedIds,
onSelectionChange: handleBoxSelectionChange, anchorId: selectionState.anchorId,
shouldStartSelection, onSelectionChange: handleBoxSelectionChange,
}); shouldStartSelection,
});
const handleBackgroundClick = useCallback( const handleBackgroundClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => { (event: React.MouseEvent<HTMLDivElement>) => {
@ -236,7 +237,7 @@ export function SelectableSurface({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [getItemElement, onRevealComplete, revealId]); }, [getItemElement, onRevealComplete, revealId]);
const isBoxSelecting = selectionBox?.isActive ?? false; const isBoxSelecting = isSelecting;
const contextValue = useMemo(() => { const contextValue = useMemo(() => {
return { return {
@ -276,12 +277,7 @@ export function SelectableSurface({
onKeyDown={handleBackgroundKeyDown} onKeyDown={handleBackgroundKeyDown}
> >
{children} {children}
<SelectionBox <SelectionBox bounds={selectionBox?.bounds ?? null} />
startPos={selectionBox?.startPos ?? null}
currentPos={selectionBox?.currentPos ?? null}
containerRef={containerRef}
isActive={selectionBox?.isActive ?? false}
/>
</div> </div>
</SelectionContext.Provider> </SelectionContext.Provider>
); );

View File

@ -1,49 +1,22 @@
"use client"; "use client";
import { useMemo } from "react"; import type { SelectionBoxBounds } from "@/selection/types";
interface SelectionBoxProps { interface SelectionBoxProps {
startPos: { x: number; y: number } | null; bounds: SelectionBoxBounds | null;
currentPos: { x: number; y: number } | null;
containerRef: React.RefObject<HTMLElement | null>;
isActive: boolean;
} }
export function SelectionBox({ export function SelectionBox({ bounds }: SelectionBoxProps) {
startPos, if (!bounds) return null;
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;
return ( return (
<div <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" className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
/> />
); );

View File

@ -8,6 +8,13 @@ export interface BoxSelectionSnapshot<TId = string> {
initialAnchorId: TId | null; initialAnchorId: TId | null;
} }
export interface SelectionBoxBounds {
left: number;
top: number;
width: number;
height: number;
}
export interface BoxSelectionChange<TId = string> export interface BoxSelectionChange<TId = string>
extends BoxSelectionSnapshot<TId> { extends BoxSelectionSnapshot<TId> {
intersectedIds: TId[]; intersectedIds: TId[];

View File

@ -1,5 +1,6 @@
import type { FrameRate } from "opencut-wasm"; import type { FrameRate } from "opencut-wasm";
import type { AnyBaseNode } from "./nodes/base-node"; import type { AnyBaseNode } from "./nodes/base-node";
import { createCanvasSurface } from "./canvas-utils";
import { buildFrameDescriptor } from "./compositor/frame-descriptor"; import { buildFrameDescriptor } from "./compositor/frame-descriptor";
import { wasmCompositor } from "./compositor/wasm-compositor"; import { wasmCompositor } from "./compositor/wasm-compositor";
import { resolveRenderTree } from "./resolve"; import { resolveRenderTree } from "./resolve";
@ -16,8 +17,8 @@ export type CanvasRendererParams = {
}; };
export class CanvasRenderer { export class CanvasRenderer {
canvas: OffscreenCanvas | HTMLCanvasElement; canvas: OffscreenCanvas;
context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; context: OffscreenCanvasRenderingContext2D;
width: number; width: number;
height: number; height: number;
fps: FrameRate; fps: FrameRate;
@ -27,22 +28,9 @@ export class CanvasRenderer {
this.height = height; this.height = height;
this.fps = fps; this.fps = fps;
try { const surface = createCanvasSurface({ width, height });
this.canvas = new OffscreenCanvas(width, height); this.canvas = surface.canvas;
} catch { this.context = surface.context;
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;
} }
getOutputCanvas(): HTMLCanvasElement { getOutputCanvas(): HTMLCanvasElement {
@ -57,20 +45,9 @@ export class CanvasRenderer {
this.width = width; this.width = width;
this.height = height; this.height = height;
if (this.canvas instanceof OffscreenCanvas) { const surface = createCanvasSurface({ width, height });
this.canvas = new OffscreenCanvas(width, height); this.canvas = surface.canvas;
} else { this.context = surface.context;
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;
} }
async render({ node, time }: { node: AnyBaseNode; time: number }) { async render({ node, time }: { node: AnyBaseNode; time: number }) {

View File

@ -1,16 +1,17 @@
export function createOffscreenCanvas({ export function createCanvasSurface({
width, width,
height, height,
}: { }: {
width: number; width: number;
height: number; height: number;
}): OffscreenCanvas | HTMLCanvasElement { }): {
try { canvas: OffscreenCanvas;
return new OffscreenCanvas(width, height); context: OffscreenCanvasRenderingContext2D;
} catch { } {
const canvas = document.createElement("canvas"); const canvas = new OffscreenCanvas(width, height);
canvas.width = width; const context = canvas.getContext("2d");
canvas.height = height; if (!context) {
return canvas; throw new Error("Failed to create 2D rendering context");
} }
return { canvas, context };
} }

View File

@ -3,7 +3,7 @@ import { masksRegistry } from "@/masks";
import { incrementCounter } from "@/diagnostics/render-perf"; import { incrementCounter } from "@/diagnostics/render-perf";
import type { AnyBaseNode } from "../nodes/base-node"; import type { AnyBaseNode } from "../nodes/base-node";
import type { CanvasRenderer } from "../canvas-renderer"; import type { CanvasRenderer } from "../canvas-renderer";
import { createOffscreenCanvas } from "../canvas-utils"; import { createCanvasSurface } from "../canvas-utils";
import { BlurBackgroundNode } from "../nodes/blur-background-node"; import { BlurBackgroundNode } from "../nodes/blur-background-node";
import { ColorNode } from "../nodes/color-node"; import { ColorNode } from "../nodes/color-node";
import { EffectLayerNode } from "../nodes/effect-layer-node"; import { EffectLayerNode } from "../nodes/effect-layer-node";
@ -414,15 +414,11 @@ function buildMaskArtifacts({
const { width: canvasWidth, height: canvasHeight } = renderer; 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}:direct=${shouldRenderMaskDirectly}`;
const drawMask: TextureCanvasDrawFn = (ctx) => { const drawMask: TextureCanvasDrawFn = (ctx) => {
const elementMaskCanvas = createOffscreenCanvas({ const { canvas: elementMaskCanvas, context: elementMaskCtx } =
width: Math.round(transform.width), createCanvasSurface({
height: Math.round(transform.height), 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) { if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
definition.renderer.renderMask({ definition.renderer.renderMask({
@ -465,15 +461,10 @@ function buildMaskArtifacts({
const strokeTextureId = `${path}:mask-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}`;
const drawStroke: TextureCanvasDrawFn = (ctx) => { const drawStroke: TextureCanvasDrawFn = (ctx) => {
const strokeCanvas = createOffscreenCanvas({ const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({
width: Math.round(transform.width), width: Math.round(transform.width),
height: Math.round(transform.height), height: Math.round(transform.height),
}); });
const strokeCtx = strokeCanvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!strokeCtx) return;
if (definition.renderer.renderStroke) { if (definition.renderer.renderStroke) {
definition.renderer.renderStroke({ definition.renderer.renderStroke({

View File

@ -1,4 +1,4 @@
import { createOffscreenCanvas } from "./canvas-utils"; import { createCanvasSurface } from "./canvas-utils";
import { effectsRegistry, resolveEffectPasses } from "@/effects"; import { effectsRegistry, resolveEffectPasses } from "@/effects";
import { buildDefaultParamValues } from "@/params/registry"; import { buildDefaultParamValues } from "@/params/registry";
import type { ParamValues } from "@/params"; import type { ParamValues } from "@/params";
@ -8,7 +8,7 @@ const PREVIEW_SIZE = 160;
const PREVIEW_IMAGE_PATH = "/effects/preview.jpg"; const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
class EffectPreviewService { class EffectPreviewService {
private testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; private testSourceCanvas: OffscreenCanvas | null = null;
private previewImageElement: HTMLImageElement | null = null; private previewImageElement: HTMLImageElement | null = null;
private onReadyCallbacks = new Set<() => void>(); private onReadyCallbacks = new Set<() => void>();
@ -98,7 +98,7 @@ class EffectPreviewService {
}: { }: {
width: number; width: number;
height: number; height: number;
}): OffscreenCanvas | HTMLCanvasElement | null { }): OffscreenCanvas | null {
const isImageReady = const isImageReady =
this.previewImageElement?.complete && this.previewImageElement?.complete &&
(this.previewImageElement.naturalWidth ?? 0) > 0; (this.previewImageElement.naturalWidth ?? 0) > 0;
@ -106,15 +106,8 @@ class EffectPreviewService {
return null; return null;
} }
const canvas = createOffscreenCanvas({ width, height }); const { canvas, context } = createCanvasSurface({ width, height });
const ctx = canvas.getContext("2d") as context.drawImage(this.previewImageElement, 0, 0, width, height);
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!ctx) {
throw new Error("failed to get 2d context for test source");
}
ctx.drawImage(this.previewImageElement, 0, 0, width, height);
return canvas; return canvas;
} }
@ -124,7 +117,7 @@ class EffectPreviewService {
}: { }: {
width: number; width: number;
height: number; height: number;
}): CanvasImageSource | null { }): OffscreenCanvas | null {
if ( if (
!this.testSourceCanvas || !this.testSourceCanvas ||
this.testSourceCanvas.width !== width || this.testSourceCanvas.width !== width ||
@ -141,17 +134,17 @@ class EffectPreviewService {
height, height,
passes, passes,
}: { }: {
source: CanvasImageSource; source: OffscreenCanvas;
width: number; width: number;
height: number; height: number;
passes: ReturnType<typeof resolveEffectPasses>; passes: ReturnType<typeof resolveEffectPasses>;
}): OffscreenCanvas | HTMLCanvasElement { }): OffscreenCanvas {
return gpuRenderer.applyEffect({ return gpuRenderer.applyEffect({
source, source,
width, width,
height, height,
passes, passes,
}) as OffscreenCanvas | HTMLCanvasElement; });
} }
} }

View File

@ -34,23 +34,17 @@ export const gpuRenderer = {
height, height,
passes, passes,
}: { }: {
source: CanvasImageSource; source: OffscreenCanvas;
width: number; width: number;
height: number; height: number;
passes: EffectPass[]; passes: EffectPass[];
}): CanvasImageSource { }): OffscreenCanvas {
if (passes.length === 0 || !gpuAvailable) { if (passes.length === 0 || !gpuAvailable) {
return source; return source;
} }
const sourceCanvas = ensureOffscreenCanvas({
source,
width,
height,
label: "effect source",
});
return applyEffectPasses({ return applyEffectPasses({
source: sourceCanvas, source,
width, width,
height, height,
passes: serializeEffectPasses(passes), passes: serializeEffectPasses(passes),
@ -63,23 +57,17 @@ export const gpuRenderer = {
height, height,
feather, feather,
}: { }: {
maskCanvas: CanvasImageSource; maskCanvas: OffscreenCanvas;
width: number; width: number;
height: number; height: number;
feather: number; feather: number;
}): CanvasImageSource { }): OffscreenCanvas {
if (!gpuAvailable) { if (!gpuAvailable) {
return maskCanvas; return maskCanvas;
} }
const sourceCanvas = ensureOffscreenCanvas({
source: maskCanvas,
width,
height,
label: "mask source",
});
return applyMaskFeatherWasm({ return applyMaskFeatherWasm({
mask: sourceCanvas, mask: maskCanvas,
width, width,
height, height,
feather, 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[]) { function serializeEffectPasses(passes: EffectPass[]) {
return passes.map((pass) => ({ return passes.map((pass) => ({
shader: pass.shader, shader: pass.shader,

View File

@ -6,15 +6,15 @@ export function applyMaskFeather({
height, height,
feather, feather,
}: { }: {
maskCanvas: CanvasImageSource; maskCanvas: OffscreenCanvas;
width: number; width: number;
height: number; height: number;
feather: number; feather: number;
}): OffscreenCanvas | HTMLCanvasElement { }): OffscreenCanvas {
return gpuRenderer.applyMaskFeather({ return gpuRenderer.applyMaskFeather({
maskCanvas, maskCanvas,
width, width,
height, height,
feather, feather,
}) as OffscreenCanvas | HTMLCanvasElement; });
} }

View File

@ -1,4 +1,4 @@
import { createOffscreenCanvas } from "../canvas-utils"; import { createCanvasSurface } from "../canvas-utils";
import { import {
DEFAULT_GRAPHIC_SOURCE_SIZE, DEFAULT_GRAPHIC_SOURCE_SIZE,
getGraphicDefinition, getGraphicDefinition,
@ -25,7 +25,7 @@ export class GraphicNode extends VisualNode<
ResolvedGraphicNodeState ResolvedGraphicNodeState
> { > {
private cachedKey: string | null = null; private cachedKey: string | null = null;
private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null; private cachedSource: OffscreenCanvas | null = null;
constructor(params: GraphicNodeParams) { constructor(params: GraphicNodeParams) {
super(params); super(params);
@ -36,7 +36,7 @@ export class GraphicNode extends VisualNode<
resolvedParams, resolvedParams,
}: { }: {
resolvedParams: ParamValues; resolvedParams: ParamValues;
}): OffscreenCanvas | HTMLCanvasElement | null { }): OffscreenCanvas {
const definition = getGraphicDefinition({ const definition = getGraphicDefinition({
definitionId: this.params.definitionId, definitionId: this.params.definitionId,
}); });
@ -48,20 +48,13 @@ export class GraphicNode extends VisualNode<
return this.cachedSource; return this.cachedSource;
} }
const canvas = createOffscreenCanvas({ const { canvas, context } = createCanvasSurface({
width: DEFAULT_GRAPHIC_SOURCE_SIZE, width: DEFAULT_GRAPHIC_SOURCE_SIZE,
height: DEFAULT_GRAPHIC_SOURCE_SIZE, height: DEFAULT_GRAPHIC_SOURCE_SIZE,
}); });
const ctx = canvas.getContext("2d") as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
if (!ctx) {
return null;
}
definition.render({ definition.render({
ctx, ctx: context,
params: resolvedParams, params: resolvedParams,
width: DEFAULT_GRAPHIC_SOURCE_SIZE, width: DEFAULT_GRAPHIC_SOURCE_SIZE,
height: DEFAULT_GRAPHIC_SOURCE_SIZE, height: DEFAULT_GRAPHIC_SOURCE_SIZE,

View File

@ -17,10 +17,13 @@ export interface CachedImageSource {
const imageSourceCache = new Map<string, Promise<CachedImageSource>>(); const imageSourceCache = new Map<string, Promise<CachedImageSource>>();
export function loadImageSource( export function loadImageSource({
url: string, url,
maxSourceSize?: number, maxSourceSize,
): Promise<CachedImageSource> { }: {
url: string;
maxSourceSize?: number;
}): Promise<CachedImageSource> {
const cacheKey = `${url}::${maxSourceSize ?? "full"}`; const cacheKey = `${url}::${maxSourceSize ?? "full"}`;
const cached = imageSourceCache.get(cacheKey); const cached = imageSourceCache.get(cacheKey);

View File

@ -235,10 +235,10 @@ async function resolveImageNode({
node: ImageNode; node: ImageNode;
context: ResolveContext; context: ResolveContext;
}): Promise<ResolvedVisualSourceNodeState | null> { }): Promise<ResolvedVisualSourceNodeState | null> {
const source = await loadImageSource( const source = await loadImageSource({
node.params.url, url: node.params.url,
node.params.maxSourceSize, maxSourceSize: node.params.maxSourceSize,
); });
const visualState = resolveVisualState({ const visualState = resolveVisualState({
params: node.params, params: node.params,
context, context,
@ -434,7 +434,7 @@ async function resolveBackdropSource({
}; };
} }
const source = await loadImageSource(node.params.url); const source = await loadImageSource({ url: node.params.url });
return { return {
source: source.source, source: source.source,
width: source.width, width: source.width,

View File

@ -5,7 +5,15 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> {
private storeName: string; private storeName: string;
private version: number; 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.dbName = dbName;
this.storeName = storeName; this.storeName = storeName;
this.version = version; 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 db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite"); const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName); const store = transaction.objectStore(this.storeName);

View File

@ -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);
}

View File

@ -8,6 +8,7 @@ import {
v0ProjectWithMetadata, v0ProjectWithMetadata,
v1Project, v1Project,
} from "./fixtures"; } from "./fixtures";
import { asArray, asRecord, asRecordArray } from "./helpers";
describe("V0 to V1 Migration", () => { describe("V0 to V1 Migration", () => {
const fixedDate = new Date("2024-06-01T12:00:00.000Z"); 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.skipped).toBe(false);
expect(result.project.version).toBe(1); expect(result.project.version).toBe(1);
expect(Array.isArray(result.project.scenes)).toBe(true); 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(); expect(result.project.currentSceneId).toBeDefined();
}); });
@ -32,7 +33,7 @@ describe("V0 to V1 Migration", () => {
options: { now: fixedDate }, options: { now: fixedDate },
}); });
const scenes = result.project.scenes as Array<Record<string, unknown>>; const scenes = asRecordArray(result.project.scenes);
const mainScene = scenes[0]; const mainScene = scenes[0];
expect(mainScene.isMain).toBe(true); expect(mainScene.isMain).toBe(true);
@ -48,7 +49,7 @@ describe("V0 to V1 Migration", () => {
options: { now: fixedDate }, options: { now: fixedDate },
}); });
const metadata = result.project.metadata as Record<string, unknown>; const metadata = asRecord(result.project.metadata);
expect(metadata.updatedAt).toBe(fixedDate.toISOString()); expect(metadata.updatedAt).toBe(fixedDate.toISOString());
}); });

View File

@ -11,6 +11,7 @@ import {
v1ProjectWithMultipleScenes, v1ProjectWithMultipleScenes,
v2Project, v2Project,
} from "./fixtures"; } from "./fixtures";
import { asRecord, asRecordArray } from "./helpers";
const DEFAULT_FPS = 30; const DEFAULT_FPS = 30;
const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
@ -25,7 +26,7 @@ describe("V1 to V2 Migration", () => {
expect(result.skipped).toBe(false); expect(result.skipped).toBe(false);
expect(result.project.version).toBe(2); 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.id).toBe(v1Project.id);
expect(metadata.name).toBe(v1Project.name); expect(metadata.name).toBe(v1Project.name);
expect(typeof metadata.createdAt).toBe("string"); expect(typeof metadata.createdAt).toBe("string");
@ -35,7 +36,7 @@ describe("V1 to V2 Migration", () => {
test("creates settings object from flat properties", () => { test("creates settings object from flat properties", () => {
const result = transformProjectV1ToV2({ project: v1Project }); 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.fps).toBe(v1Project.fps);
expect(settings.canvasSize).toEqual(v1Project.canvasSize); expect(settings.canvasSize).toEqual(v1Project.canvasSize);
expect(settings.originalCanvasSize).toBe(null); expect(settings.originalCanvasSize).toBe(null);
@ -44,8 +45,8 @@ describe("V1 to V2 Migration", () => {
test("converts color background correctly", () => { test("converts color background correctly", () => {
const result = transformProjectV1ToV2({ project: v1Project }); const result = transformProjectV1ToV2({ project: v1Project });
const settings = result.project.settings as Record<string, unknown>; const settings = asRecord(result.project.settings);
const background = settings.background as Record<string, unknown>; const background = asRecord(settings.background);
expect(background.type).toBe("color"); expect(background.type).toBe("color");
expect(background.color).toBe(v1Project.backgroundColor); expect(background.color).toBe(v1Project.backgroundColor);
}); });
@ -58,8 +59,8 @@ describe("V1 to V2 Migration", () => {
}; };
const result = transformProjectV1ToV2({ project: projectWithBlur }); const result = transformProjectV1ToV2({ project: projectWithBlur });
const settings = result.project.settings as Record<string, unknown>; const settings = asRecord(result.project.settings);
const background = settings.background as Record<string, unknown>; const background = asRecord(settings.background);
expect(background.type).toBe("blur"); expect(background.type).toBe("blur");
expect(background.blurIntensity).toBe(30); expect(background.blurIntensity).toBe(30);
}); });
@ -67,7 +68,7 @@ describe("V1 to V2 Migration", () => {
test("applies legacy bookmarks to main scene", () => { test("applies legacy bookmarks to main scene", () => {
const result = transformProjectV1ToV2({ project: v1Project }); 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); const mainScene = scenes.find((s) => s.isMain === true);
expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks); expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks);
}); });
@ -77,7 +78,7 @@ describe("V1 to V2 Migration", () => {
project: v1ProjectWithMultipleScenes, 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"); const introScene = scenes.find((s) => s.name === "Intro");
expect(introScene?.bookmarks).toEqual([1.0]); expect(introScene?.bookmarks).toEqual([1.0]);
}); });
@ -102,7 +103,7 @@ describe("V1 to V2 Migration", () => {
}); });
expect(result.skipped).toBe(false); 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.fps).toBe(DEFAULT_FPS);
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE);
}); });
@ -115,11 +116,11 @@ describe("V1 to V2 Migration", () => {
}; };
const result = transformProjectV1ToV2({ project: minimalProject }); 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.fps).toBe(DEFAULT_FPS);
expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); 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.type).toBe("color");
expect(background.color).toBe(DEFAULT_BACKGROUND_COLOR); expect(background.color).toBe(DEFAULT_BACKGROUND_COLOR);
}); });
@ -135,8 +136,8 @@ describe("V1 to V2 Migration", () => {
project: projectWithBlurNoIntensity, project: projectWithBlurNoIntensity,
}); });
const settings = result.project.settings as Record<string, unknown>; const settings = asRecord(result.project.settings);
const background = settings.background as Record<string, unknown>; const background = asRecord(settings.background);
expect(background.blurIntensity).toBe(DEFAULT_BACKGROUND_BLUR_INTENSITY); expect(background.blurIntensity).toBe(DEFAULT_BACKGROUND_BLUR_INTENSITY);
}); });
@ -183,9 +184,9 @@ describe("V1 to V2 Migration", () => {
project: projectWithTracks, project: projectWithTracks,
}); });
const scenes = result.project.scenes as Array<Record<string, unknown>>; const scenes = asRecordArray(result.project.scenes);
const mainScene = scenes[0]; 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.length).toBe(1);
expect(tracks[0].name).toBe("Existing Track"); expect(tracks[0].name).toBe("Existing Track");
}); });
@ -240,9 +241,9 @@ describe("V1 to V2 Migration", () => {
context, context,
}); });
const scenes = result.project.scenes as Array<Record<string, unknown>>; const scenes = asRecordArray(result.project.scenes);
const mainScene = scenes[0]; 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(Array.isArray(tracks)).toBe(true);
expect(tracks).toHaveLength(1); expect(tracks).toHaveLength(1);
expect(tracks[0].type).toBe("video"); expect(tracks[0].type).toBe("video");
@ -302,9 +303,9 @@ describe("V1 to V2 Migration", () => {
}); });
expect(result.skipped).toBe(false); expect(result.skipped).toBe(false);
const scenes = result.project.scenes as Array<Record<string, unknown>>; const scenes = asRecordArray(result.project.scenes);
const tracks = scenes[0].tracks as Array<Record<string, unknown>>; const tracks = asRecordArray(scenes[0].tracks);
const elements = tracks[0].elements as Array<Record<string, unknown>>; const elements = asRecordArray(tracks[0].elements);
const textElement = elements[0]; const textElement = elements[0];
expect(textElement.opacity).toBe(0.5); expect(textElement.opacity).toBe(0.5);
expect(textElement.transform).toEqual({ expect(textElement.transform).toEqual({

View File

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { transformProjectV15ToV16 } from "../transformers/v15-to-v16"; import { transformProjectV15ToV16 } from "../transformers/v15-to-v16";
import { asRecordArray } from "./helpers";
describe("V15 to V16 Migration", () => { describe("V15 to V16 Migration", () => {
test("renames sticker tracks to graphic tracks", () => { test("renames sticker tracks to graphic tracks", () => {
@ -61,10 +62,10 @@ describe("V15 to V16 Migration", () => {
expect(result.skipped).toBe(false); expect(result.skipped).toBe(false);
expect(result.project.version).toBe(16); expect(result.project.version).toBe(16);
expect( const firstTrack = asRecordArray(
(result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0] asRecordArray(result.project.scenes)[0].tracks,
.tracks[0].type, )[0];
).toBe("graphic"); expect(firstTrack.type).toBe("graphic");
}); });
test("skips projects already on v16", () => { test("skips projects already on v16", () => {

View File

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { transformProjectV16ToV17 } from "../transformers/v16-to-v17"; import { transformProjectV16ToV17 } from "../transformers/v16-to-v17";
import { asRecord, asRecordArray } from "./helpers";
describe("V16 to V17 Migration", () => { describe("V16 to V17 Migration", () => {
test("adds center stroke alignment to masks that do not have it", () => { 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.skipped).toBe(false);
expect(result.project.version).toBe(17); expect(result.project.version).toBe(17);
const migratedMasks = ( const firstScene = asRecordArray(result.project.scenes)[0];
((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record<string, unknown> }> }> }> }>)[0] const firstTrack = asRecordArray(firstScene.tracks)[0];
.tracks[0].elements[0].masks) const firstElement = asRecordArray(firstTrack.elements)[0];
); const migratedMasks = asRecordArray(firstElement.masks);
expect(migratedMasks[0].params.strokeAlign).toBe("center"); expect(asRecord(migratedMasks[0].params).strokeAlign).toBe("center");
expect(migratedMasks[1].params.strokeAlign).toBe("outside"); expect(asRecord(migratedMasks[1].params).strokeAlign).toBe("outside");
}); });
test("skips projects already on v17", () => { test("skips projects already on v17", () => {

View File

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { transformProjectV18ToV19 } from "../transformers/v18-to-v19"; import { transformProjectV18ToV19 } from "../transformers/v18-to-v19";
import { asRecord } from "./helpers";
describe("V18 to V19 Migration", () => { describe("V18 to V19 Migration", () => {
test("adds canvas size mode and empty remembered custom size defaults", () => { 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.skipped).toBe(false);
expect(result.project.version).toBe(19); expect(result.project.version).toBe(19);
expect( const settings = asRecord(result.project.settings);
(result.project.settings as Record<string, unknown>).canvasSizeMode, expect(settings.canvasSizeMode).toBe("preset");
).toBe("preset"); expect(settings.lastCustomCanvasSize).toBeNull();
expect( expect(settings.originalCanvasSize).toEqual({
(result.project.settings as Record<string, unknown>).lastCustomCanvasSize, width: 1920,
).toBeNull(); height: 1080,
expect( });
(result.project.settings as Record<string, unknown>).originalCanvasSize,
).toEqual({ width: 1920, height: 1080 });
}); });
test("skips projects already on v19", () => { test("skips projects already on v19", () => {

View File

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { transformProjectV19ToV20 } from "../transformers/v19-to-v20"; import { transformProjectV19ToV20 } from "../transformers/v19-to-v20";
import { asRecordArray } from "./helpers";
describe("V19 to V20 Migration", () => { describe("V19 to V20 Migration", () => {
test("backfills source audio enabled state on video elements", () => { 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.skipped).toBe(false);
expect(result.project.version).toBe(20); expect(result.project.version).toBe(20);
expect( const scene = asRecordArray(result.project.scenes)[0];
((result.project.scenes as Array<Record<string, unknown>>)[0] const tracks = asRecordArray(scene.tracks);
.tracks as Array<Record<string, unknown>>)[0].elements, expect(tracks[0].elements).toEqual([
).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "video-1", id: "video-1",
isSourceAudioEnabled: true, isSourceAudioEnabled: true,
}), }),
]); ]);
expect( expect(
(((result.project.scenes as Array<Record<string, unknown>>)[0] asRecordArray(tracks[1].elements)[0].isSourceAudioEnabled,
.tracks as Array<Record<string, unknown>>)[1]
.elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
).toBeUndefined(); ).toBeUndefined();
}); });
@ -120,11 +118,10 @@ describe("V19 to V20 Migration", () => {
}, },
}); });
expect( const scene = asRecordArray(result.project.scenes)[0];
(((result.project.scenes as Array<Record<string, unknown>>)[0] const track = asRecordArray(scene.tracks)[0];
.tracks as Array<Record<string, unknown>>)[0] const element = asRecordArray(track.elements)[0];
.elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled, expect(element.isSourceAudioEnabled).toBe(false);
).toBe(false);
}); });
test("skips projects already on v20", () => { test("skips projects already on v20", () => {

View File

@ -8,6 +8,7 @@ import {
v2ProjectWithBlurBackground, v2ProjectWithBlurBackground,
v3Project, v3Project,
} from "./fixtures"; } from "./fixtures";
import { asRecord } from "./helpers";
describe("V2 to V3 Migration", () => { describe("V2 to V3 Migration", () => {
describe("transformProjectV2ToV3", () => { describe("transformProjectV2ToV3", () => {
@ -17,14 +18,14 @@ describe("V2 to V3 Migration", () => {
expect(result.skipped).toBe(false); expect(result.skipped).toBe(false);
expect(result.project.version).toBe(3); 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"); expect(typeof metadata.duration).toBe("number");
}); });
test("calculates duration from scene tracks", () => { test("calculates duration from scene tracks", () => {
const result = transformProjectV2ToV3({ project: v2Project }); 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 // 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 // Total duration should be max(15.5, 2+5) = 15.5
expect(metadata.duration).toBe(15.5); expect(metadata.duration).toBe(15.5);
@ -36,7 +37,7 @@ describe("V2 to V3 Migration", () => {
}); });
expect(result.skipped).toBe(false); 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 // v2ProjectWithBlurBackground has a video with duration 30
expect(metadata.duration).toBe(30); expect(metadata.duration).toBe(30);
}); });
@ -45,7 +46,7 @@ describe("V2 to V3 Migration", () => {
const result = transformProjectV2ToV3({ project: v2ProjectEmptyScenes }); const result = transformProjectV2ToV3({ project: v2ProjectEmptyScenes });
expect(result.skipped).toBe(false); 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); expect(metadata.duration).toBe(0);
}); });
@ -55,7 +56,7 @@ describe("V2 to V3 Migration", () => {
}); });
expect(result.skipped).toBe(false); 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); expect(metadata.duration).toBe(0);
}); });
@ -91,7 +92,7 @@ describe("V2 to V3 Migration", () => {
test("preserves existing metadata fields", () => { test("preserves existing metadata fields", () => {
const result = transformProjectV2ToV3({ project: v2Project }); 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.id).toBe(v2Project.metadata.id);
expect(metadata.name).toBe(v2Project.metadata.name); expect(metadata.name).toBe(v2Project.metadata.name);
expect(metadata.thumbnail).toBe(v2Project.metadata.thumbnail); expect(metadata.thumbnail).toBe(v2Project.metadata.thumbnail);
@ -122,7 +123,7 @@ describe("V2 to V3 Migration", () => {
}); });
expect(result.skipped).toBe(false); 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); expect(metadata.duration).toBe(0);
}); });
@ -156,7 +157,7 @@ describe("V2 to V3 Migration", () => {
}; };
const result = transformProjectV2ToV3({ project: multiSceneProject }); 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 // Duration is from main scene only, not sum of all scenes
expect(metadata.duration).toBe(10); expect(metadata.duration).toBe(10);
}); });

View File

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { transformProjectV20ToV21 } from "../transformers/v20-to-v21"; import { transformProjectV20ToV21 } from "../transformers/v20-to-v21";
import { asRecord } from "./helpers";
const LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY = 50; const LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY = 50;
@ -27,8 +28,8 @@ describe("V20 to V21 Migration", () => {
expect(result.skipped).toBe(false); expect(result.skipped).toBe(false);
expect(result.project.version).toBe(21); expect(result.project.version).toBe(21);
const settings = result.project.settings as Record<string, unknown>; const settings = asRecord(result.project.settings);
const background = settings.background as Record<string, unknown>; const background = asRecord(settings.background);
expect(background.blurIntensity).toBe(500); expect(background.blurIntensity).toBe(500);
}); });
@ -45,8 +46,8 @@ describe("V20 to V21 Migration", () => {
}); });
expect(result.skipped).toBe(false); expect(result.skipped).toBe(false);
const settings = result.project.settings as Record<string, unknown>; const settings = asRecord(result.project.settings);
const background = settings.background as Record<string, unknown>; const background = asRecord(settings.background);
expect(background.blurIntensity).toBe( expect(background.blurIntensity).toBe(
LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY, LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY,
); );
@ -65,7 +66,7 @@ describe("V20 to V21 Migration", () => {
}); });
expect(result.skipped).toBe(false); 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" }); expect(settings.background).toEqual({ type: "color", color: "#000000" });
}); });

Some files were not shown because too many files have changed in this diff Show More