diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f251603f..5edc56fb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -168,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README 2. Make your changes 3. Run the relevant checks for the area you touched: - - Web changes: from `apps/web`, run `bun run lint` and `bunx biome format --write .` + - Web changes: from `apps/web`, run `bun run lint` and `bun run format` - Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet 4. Commit your changes with a descriptive message @@ -176,8 +176,8 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README ## Code Style -- We use Biome for code formatting and linting -- Run `bunx biome format --write .` from the `apps/web` directory to format code +- We use ESLint for linting and Prettier for formatting +- Run `bun run format` from the `apps/web` directory to format code - Run `bun run lint` from the `apps/web` directory to check for linting issues - Follow the existing code patterns diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c30aecc2..77d2c4f0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,344 +1,342 @@ ---- -applyTo: "**/*.{ts,tsx,js,jsx}" ---- - -# Project Context - -Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. - -## Key Principles - -- Zero configuration required -- Subsecond performance -- Maximum type safety -- AI-friendly code generation - -## Before Writing Code - -1. Analyze existing patterns in the codebase -2. Consider edge cases and error scenarios -3. Follow the rules below strictly -4. Validate accessibility requirements - -## Rules - -### Accessibility (a11y) - -- Don't use `accessKey` attribute on any HTML element. -- Don't set `aria-hidden="true"` on focusable elements. -- Don't add ARIA roles, states, and properties to elements that don't support them. -- Don't use distracting elements like `` or ``. -- Only use the `scope` prop on `` elements. -- Don't assign non-interactive ARIA roles to interactive HTML elements. -- Make sure label elements have text content and are associated with an input. -- Don't assign interactive ARIA roles to non-interactive HTML elements. -- Don't assign `tabIndex` to non-interactive HTML elements. -- Don't use positive integers for `tabIndex` property. -- Don't include "image", "picture", or "photo" in img alt prop. -- Don't use explicit role property that's the same as the implicit/default role. -- Make static elements with click handlers use a valid role attribute. -- Always include a `title` element for SVG elements. -- Give all elements requiring alt text meaningful information for screen readers. -- Make sure anchors have content that's accessible to screen readers. -- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. -- Include all required ARIA attributes for elements with ARIA roles. -- Make sure ARIA properties are valid for the element's supported roles. -- Always include a `type` attribute for button elements. -- Make elements with interactive roles and handlers focusable. -- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). -- Always include a `lang` attribute on the html element. -- Always include a `title` attribute for iframe elements. -- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. -- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. -- Include caption tracks for audio and video elements. -- Use semantic elements instead of role attributes in JSX. -- Make sure all anchors are valid and navigable. -- Ensure all ARIA properties (`aria-*`) are valid. -- Use valid, non-abstract ARIA roles for elements with ARIA roles. -- Use valid ARIA state and property values. -- Use valid values for the `autocomplete` attribute on input elements. -- Use correct ISO language/country codes for the `lang` attribute. - -### Code Complexity and Quality - -- Don't use consecutive spaces in regular expression literals. -- Don't use the `arguments` object. -- Don't use primitive type aliases or misleading types. -- Don't use the comma operator. -- Don't use empty type parameters in type aliases and interfaces. -- Don't write functions that exceed a given Cognitive Complexity score. -- Don't nest describe() blocks too deeply in test files. -- Don't use unnecessary boolean casts. -- Don't use unnecessary callbacks with flatMap. -- Use for...of statements instead of Array.forEach. -- Don't create classes that only have static members (like a static namespace). -- Don't use this and super in static contexts. -- Don't use unnecessary catch clauses. -- Don't use unnecessary constructors. -- Don't use unnecessary continue statements. -- Don't export empty modules that don't change anything. -- Don't use unnecessary escape sequences in regular expression literals. -- Don't use unnecessary fragments. -- Don't use unnecessary labels. -- Don't use unnecessary nested block statements. -- Don't rename imports, exports, and destructured assignments to the same name. -- Don't use unnecessary string or template literal concatenation. -- Don't use String.raw in template literals when there are no escape sequences. -- Don't use useless case statements in switch statements. -- Don't use ternary operators when simpler alternatives exist. -- Don't use useless `this` aliasing. -- Don't use any or unknown as type constraints. -- Don't initialize variables to undefined. -- Don't use the void operators (they're not familiar). -- Use arrow functions instead of function expressions. -- Use Date.now() to get milliseconds since the Unix Epoch. -- Use .flatMap() instead of map().flat() when possible. -- Use literal property access instead of computed property access. -- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. -- Use concise optional chaining instead of chained logical expressions. -- Use regular expression literals instead of the RegExp constructor when possible. -- Don't use number literal object member names that aren't base 10 or use underscore separators. -- Remove redundant terms from logical expressions. -- Use while loops instead of for loops when you don't need initializer and update expressions. -- Don't pass children as props. -- Don't reassign const variables. -- Don't use constant expressions in conditions. -- Don't use `Math.min` and `Math.max` to clamp values when the result is constant. -- Don't return a value from a constructor. -- Don't use empty character classes in regular expression literals. -- Don't use empty destructuring patterns. -- Don't call global object properties as functions. -- Don't declare functions and vars that are accessible outside their block. -- Make sure builtins are correctly instantiated. -- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. -- Don't use variables and function parameters before they're declared. -- Don't use 8 and 9 escape sequences in string literals. -- Don't use literal numbers that lose precision. - -### React and JSX Best Practices - -- Don't use the return value of React.render. -- Make sure all dependencies are correctly specified in React hooks. -- Make sure all React hooks are called from the top level of component functions. -- Don't forget key props in iterators and collection literals. -- Don't destructure props inside JSX components in Solid projects. -- Don't define React components inside other components. -- Don't use event handlers on non-interactive elements. -- Don't assign to React component props. -- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. -- Don't use dangerous JSX props. -- Don't use Array index in keys. -- Don't insert comments as text nodes. -- Don't assign JSX properties multiple times. -- Don't add extra closing tags for components without children. -- Use `<>...` instead of `...`. -- Watch out for possible "wrong" semicolons inside JSX elements. - -### Correctness and Safety - -- Don't assign a value to itself. -- Don't return a value from a setter. -- Don't compare expressions that modify string case with non-compliant values. -- Don't use lexical declarations in switch clauses. -- Don't use variables that haven't been declared in the document. -- Don't write unreachable code. -- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass. -- Don't use control flow statements in finally blocks. -- Don't use optional chaining where undefined values aren't allowed. -- Don't have unused function parameters. -- Don't have unused imports. -- Don't have unused labels. -- Don't have unused private class members. -- Don't have unused variables. -- Make sure void (self-closing) elements don't have children. -- Don't return a value from a function with the return type 'void' -- Use isNaN() when checking for NaN. -- Make sure "for" loop update clauses move the counter in the right direction. -- Make sure typeof expressions are compared to valid values. -- Make sure generator functions contain yield. -- Don't use await inside loops. -- Don't use bitwise operators. -- Don't use expressions where the operation doesn't change the value. -- Make sure Promise-like statements are handled appropriately. -- Don't use **dirname and **filename in the global scope. -- Prevent import cycles. -- Don't use configured elements. -- Don't hardcode sensitive data like API keys and tokens. -- Don't let variable declarations shadow variables from outer scopes. -- Don't use the TypeScript directive @ts-ignore. -- Prevent duplicate polyfills from Polyfill.io. -- Don't use useless backreferences in regular expressions that always match empty strings. -- Don't use unnecessary escapes in string literals. -- Don't use useless undefined. -- Make sure getters and setters for the same property are next to each other in class and object definitions. -- Make sure object literals are declared consistently (defaults to explicit definitions). -- Use static Response methods instead of new Response() constructor when possible. -- Make sure switch-case statements are exhaustive. -- Make sure the `preconnect` attribute is used when using Google Fonts. -- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. -- Make sure iterable callbacks return consistent values. -- Use `with { type: "json" }` for JSON module imports. -- Use numeric separators in numeric literals. -- Use object spread instead of `Object.assign()` when constructing new objects. -- Always use the radix argument when using `parseInt()`. -- Make sure JSDoc comment lines start with a single asterisk, except for the first one. -- Include a description parameter for `Symbol()`. -- Don't use spread (`...`) syntax on accumulators. -- Don't use the `delete` operator. -- Don't access namespace imports dynamically. -- Don't use namespace imports. -- Declare regex literals at the top level. -- Don't use `target="_blank"` without `rel="noopener"`. - -### TypeScript Best Practices - -- Don't use TypeScript enums. -- Don't export imported variables. -- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. -- Don't use TypeScript namespaces. -- Don't use non-null assertions with the `!` postfix operator. -- Don't use parameter properties in class constructors. -- Don't use user-defined types. -- Use `as const` instead of literal types and type annotations. -- Use either `T[]` or `Array` consistently. -- Initialize each enum member value explicitly. -- Use `export type` for types. -- Use `import type` for types. -- Make sure all enum members are literal values. -- Don't use TypeScript const enum. -- Don't declare empty interfaces. -- Don't let variables evolve into any type through reassignments. -- Don't use the any type. -- Don't misuse the non-null assertion operator (!) in TypeScript files. -- Don't use implicit any type on variable declarations. -- Don't merge interfaces and classes unsafely. -- Don't use overload signatures that aren't next to each other. -- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. - -### Style and Consistency - -- Don't use global `eval()`. -- Don't use callbacks in asynchronous tests and hooks. -- Don't use negation in `if` statements that have `else` clauses. -- Don't use nested ternary expressions. -- Don't reassign function parameters. -- This rule lets you specify global variable names you don't want to use in your application. -- Don't use specified modules when loaded by import or require. -- Don't use constants whose value is the upper-case version of their name. -- Use `String.slice()` instead of `String.substr()` and `String.substring()`. -- Don't use template literals if you don't need interpolation or special-character handling. -- Don't use `else` blocks when the `if` block breaks early. -- Don't use yoda expressions. -- Don't use Array constructors. -- Use `at()` instead of integer index access. -- Follow curly brace conventions. -- Use `else if` instead of nested `if` statements in `else` clauses. -- Use single `if` statements instead of nested `if` clauses. -- Use `new` for all builtins except `String`, `Number`, and `Boolean`. -- Use consistent accessibility modifiers on class properties and methods. -- Use `const` declarations for variables that are only assigned once. -- Put default function parameters and optional function parameters last. -- Include a `default` clause in switch statements. -- Use the `**` operator instead of `Math.pow`. -- Use `for-of` loops when you need the index to extract an item from the iterated array. -- Use `node:assert/strict` over `node:assert`. -- Use the `node:` protocol for Node.js builtin modules. -- Use Number properties instead of global ones. -- Use assignment operator shorthand where possible. -- Use function types instead of object types with call signatures. -- Use template literals over string concatenation. -- Use `new` when throwing an error. -- Don't throw non-Error values. -- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. -- Use standard constants instead of approximated literals. -- Don't assign values in expressions. -- Don't use async functions as Promise executors. -- Don't reassign exceptions in catch clauses. -- Don't reassign class members. -- Don't compare against -0. -- Don't use labeled statements that aren't loops. -- Don't use void type outside of generic or return types. -- Don't use console. -- Don't use control characters and escape sequences that match control characters in regular expression literals. -- Don't use debugger. -- Don't assign directly to document.cookie. -- Use `===` and `!==`. -- Don't use duplicate case labels. -- Don't use duplicate class members. -- Don't use duplicate conditions in if-else-if chains. -- Don't use two keys with the same name inside objects. -- Don't use duplicate function parameter names. -- Don't have duplicate hooks in describe blocks. -- Don't use empty block statements and static blocks. -- Don't let switch clauses fall through. -- Don't reassign function declarations. -- Don't allow assignments to native objects and read-only global variables. -- Use Number.isFinite instead of global isFinite. -- Use Number.isNaN instead of global isNaN. -- Don't assign to imported bindings. -- Don't use irregular whitespace characters. -- Don't use labels that share a name with a variable. -- Don't use characters made with multiple code points in character class syntax. -- Make sure to use new and constructor properly. -- Don't use shorthand assign when the variable appears on both sides. -- Don't use octal escape sequences in string literals. -- Don't use Object.prototype builtins directly. -- Don't redeclare variables, functions, classes, and types in the same scope. -- Don't have redundant "use strict". -- Don't compare things where both sides are exactly the same. -- Don't let identifiers shadow restricted names. -- Don't use sparse arrays (arrays with holes). -- Don't use template literal placeholder syntax in regular strings. -- Don't use the then property. -- Don't use unsafe negation. -- Don't use var. -- Don't use with statements in non-strict contexts. -- Make sure async functions actually use await. -- Make sure default clauses in switch statements come last. -- Make sure to pass a message value when creating a built-in error. -- Make sure get methods always return a value. -- Use a recommended display strategy with Google Fonts. -- Make sure for-in loops include an if statement. -- Use Array.isArray() instead of instanceof Array. -- Make sure to use the digits argument with Number#toFixed(). -- Make sure to use the "use strict" directive in script files. - -### Next.js Specific Rules - -- Don't use `` elements in Next.js projects. -- Don't use `` elements in Next.js projects. -- Don't import next/document outside of pages/\_document.jsx in Next.js projects. -- Don't use the next/head module in pages/\_document.js on Next.js projects. - -### Testing Best Practices - -- Don't use export or module.exports in test files. -- Don't use focused tests. -- Make sure the assertion function, like expect, is placed inside an it() function call. -- Don't use disabled tests. - -## Common Tasks - -- `npx ultracite init` - Initialize Ultracite in your project -- `npx ultracite format` - Format and fix code automatically -- `npx ultracite lint` - Check for issues without fixing - -## Example: Error Handling - -```typescript -// ✅ Good: Comprehensive error handling -try { - const result = await fetchData(); - return { success: true, data: result }; -} catch (error) { - console.error("API call failed:", error); - return { success: false, error: error.message }; -} - -// ❌ Bad: Swallowing errors -try { - return await fetchData(); -} catch (e) { - console.log(e); -} -``` +--- +applyTo: "**/*.{ts,tsx,js,jsx}" +--- + +# Project Context + +Strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects, enforced via ESLint (linting) and Prettier (formatting). + +## Key Principles + +- Maximum type safety +- AI-friendly code generation + +## Before Writing Code + +1. Analyze existing patterns in the codebase +2. Consider edge cases and error scenarios +3. Follow the rules below strictly +4. Validate accessibility requirements + +## Rules + +### Accessibility (a11y) + +- Don't use `accessKey` attribute on any HTML element. +- Don't set `aria-hidden="true"` on focusable elements. +- Don't add ARIA roles, states, and properties to elements that don't support them. +- Don't use distracting elements like `` or ``. +- Only use the `scope` prop on `` elements. +- Don't assign non-interactive ARIA roles to interactive HTML elements. +- Make sure label elements have text content and are associated with an input. +- Don't assign interactive ARIA roles to non-interactive HTML elements. +- Don't assign `tabIndex` to non-interactive HTML elements. +- Don't use positive integers for `tabIndex` property. +- Don't include "image", "picture", or "photo" in img alt prop. +- Don't use explicit role property that's the same as the implicit/default role. +- Make static elements with click handlers use a valid role attribute. +- Always include a `title` element for SVG elements. +- Give all elements requiring alt text meaningful information for screen readers. +- Make sure anchors have content that's accessible to screen readers. +- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. +- Include all required ARIA attributes for elements with ARIA roles. +- Make sure ARIA properties are valid for the element's supported roles. +- Always include a `type` attribute for button elements. +- Make elements with interactive roles and handlers focusable. +- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). +- Always include a `lang` attribute on the html element. +- Always include a `title` attribute for iframe elements. +- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. +- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. +- Include caption tracks for audio and video elements. +- Use semantic elements instead of role attributes in JSX. +- Make sure all anchors are valid and navigable. +- Ensure all ARIA properties (`aria-*`) are valid. +- Use valid, non-abstract ARIA roles for elements with ARIA roles. +- Use valid ARIA state and property values. +- Use valid values for the `autocomplete` attribute on input elements. +- Use correct ISO language/country codes for the `lang` attribute. + +### Code Complexity and Quality + +- Don't use consecutive spaces in regular expression literals. +- Don't use the `arguments` object. +- Don't use primitive type aliases or misleading types. +- Don't use the comma operator. +- Don't use empty type parameters in type aliases and interfaces. +- Don't write functions that exceed a given Cognitive Complexity score. +- Don't nest describe() blocks too deeply in test files. +- Don't use unnecessary boolean casts. +- Don't use unnecessary callbacks with flatMap. +- Use for...of statements instead of Array.forEach. +- Don't create classes that only have static members (like a static namespace). +- Don't use this and super in static contexts. +- Don't use unnecessary catch clauses. +- Don't use unnecessary constructors. +- Don't use unnecessary continue statements. +- Don't export empty modules that don't change anything. +- Don't use unnecessary escape sequences in regular expression literals. +- Don't use unnecessary fragments. +- Don't use unnecessary labels. +- Don't use unnecessary nested block statements. +- Don't rename imports, exports, and destructured assignments to the same name. +- Don't use unnecessary string or template literal concatenation. +- Don't use String.raw in template literals when there are no escape sequences. +- Don't use useless case statements in switch statements. +- Don't use ternary operators when simpler alternatives exist. +- Don't use useless `this` aliasing. +- Don't use any or unknown as type constraints. +- Don't initialize variables to undefined. +- Don't use the void operators (they're not familiar). +- Use arrow functions instead of function expressions. +- Use Date.now() to get milliseconds since the Unix Epoch. +- Use .flatMap() instead of map().flat() when possible. +- Use literal property access instead of computed property access. +- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. +- Use concise optional chaining instead of chained logical expressions. +- Use regular expression literals instead of the RegExp constructor when possible. +- Don't use number literal object member names that aren't base 10 or use underscore separators. +- Remove redundant terms from logical expressions. +- Use while loops instead of for loops when you don't need initializer and update expressions. +- Don't pass children as props. +- Don't reassign const variables. +- Don't use constant expressions in conditions. +- Don't use `Math.min` and `Math.max` to clamp values when the result is constant. +- Don't return a value from a constructor. +- Don't use empty character classes in regular expression literals. +- Don't use empty destructuring patterns. +- Don't call global object properties as functions. +- Don't declare functions and vars that are accessible outside their block. +- Make sure builtins are correctly instantiated. +- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. +- Don't use variables and function parameters before they're declared. +- Don't use 8 and 9 escape sequences in string literals. +- Don't use literal numbers that lose precision. + +### React and JSX Best Practices + +- Don't use the return value of React.render. +- Make sure all dependencies are correctly specified in React hooks. +- Make sure all React hooks are called from the top level of component functions. +- Don't forget key props in iterators and collection literals. +- Don't destructure props inside JSX components in Solid projects. +- Don't define React components inside other components. +- Don't use event handlers on non-interactive elements. +- Don't assign to React component props. +- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. +- Don't use dangerous JSX props. +- Don't use Array index in keys. +- Don't insert comments as text nodes. +- Don't assign JSX properties multiple times. +- Don't add extra closing tags for components without children. +- Use `<>...` instead of `...`. +- Watch out for possible "wrong" semicolons inside JSX elements. + +### Correctness and Safety + +- Don't assign a value to itself. +- Don't return a value from a setter. +- Don't compare expressions that modify string case with non-compliant values. +- Don't use lexical declarations in switch clauses. +- Don't use variables that haven't been declared in the document. +- Don't write unreachable code. +- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass. +- Don't use control flow statements in finally blocks. +- Don't use optional chaining where undefined values aren't allowed. +- Don't have unused function parameters. +- Don't have unused imports. +- Don't have unused labels. +- Don't have unused private class members. +- Don't have unused variables. +- Make sure void (self-closing) elements don't have children. +- Don't return a value from a function with the return type 'void' +- Use isNaN() when checking for NaN. +- Make sure "for" loop update clauses move the counter in the right direction. +- Make sure typeof expressions are compared to valid values. +- Make sure generator functions contain yield. +- Don't use await inside loops. +- Don't use bitwise operators. +- Don't use expressions where the operation doesn't change the value. +- Make sure Promise-like statements are handled appropriately. +- Don't use **dirname and **filename in the global scope. +- Prevent import cycles. +- Don't use configured elements. +- Don't hardcode sensitive data like API keys and tokens. +- Don't let variable declarations shadow variables from outer scopes. +- Don't use the TypeScript directive @ts-ignore. +- Prevent duplicate polyfills from Polyfill.io. +- Don't use useless backreferences in regular expressions that always match empty strings. +- Don't use unnecessary escapes in string literals. +- Don't use useless undefined. +- Make sure getters and setters for the same property are next to each other in class and object definitions. +- Make sure object literals are declared consistently (defaults to explicit definitions). +- Use static Response methods instead of new Response() constructor when possible. +- Make sure switch-case statements are exhaustive. +- Make sure the `preconnect` attribute is used when using Google Fonts. +- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. +- Make sure iterable callbacks return consistent values. +- Use `with { type: "json" }` for JSON module imports. +- Use numeric separators in numeric literals. +- Use object spread instead of `Object.assign()` when constructing new objects. +- Always use the radix argument when using `parseInt()`. +- Make sure JSDoc comment lines start with a single asterisk, except for the first one. +- Include a description parameter for `Symbol()`. +- Don't use spread (`...`) syntax on accumulators. +- Don't use the `delete` operator. +- Don't access namespace imports dynamically. +- Don't use namespace imports. +- Declare regex literals at the top level. +- Don't use `target="_blank"` without `rel="noopener"`. + +### TypeScript Best Practices + +- Don't use TypeScript enums. +- Don't export imported variables. +- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. +- Don't use TypeScript namespaces. +- Don't use non-null assertions with the `!` postfix operator. +- Don't use parameter properties in class constructors. +- Don't use user-defined types. +- Use `as const` instead of literal types and type annotations. +- Use either `T[]` or `Array` consistently. +- Initialize each enum member value explicitly. +- Use `export type` for types. +- Use `import type` for types. +- Make sure all enum members are literal values. +- Don't use TypeScript const enum. +- Don't declare empty interfaces. +- Don't let variables evolve into any type through reassignments. +- Don't use the any type. +- Don't misuse the non-null assertion operator (!) in TypeScript files. +- Don't use implicit any type on variable declarations. +- Don't merge interfaces and classes unsafely. +- Don't use overload signatures that aren't next to each other. +- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. + +### Style and Consistency + +- Don't use global `eval()`. +- Don't use callbacks in asynchronous tests and hooks. +- Don't use negation in `if` statements that have `else` clauses. +- Don't use nested ternary expressions. +- Don't reassign function parameters. +- This rule lets you specify global variable names you don't want to use in your application. +- Don't use specified modules when loaded by import or require. +- Don't use constants whose value is the upper-case version of their name. +- Use `String.slice()` instead of `String.substr()` and `String.substring()`. +- Don't use template literals if you don't need interpolation or special-character handling. +- Don't use `else` blocks when the `if` block breaks early. +- Don't use yoda expressions. +- Don't use Array constructors. +- Use `at()` instead of integer index access. +- Follow curly brace conventions. +- Use `else if` instead of nested `if` statements in `else` clauses. +- Use single `if` statements instead of nested `if` clauses. +- Use `new` for all builtins except `String`, `Number`, and `Boolean`. +- Use consistent accessibility modifiers on class properties and methods. +- Use `const` declarations for variables that are only assigned once. +- Put default function parameters and optional function parameters last. +- Include a `default` clause in switch statements. +- Use the `**` operator instead of `Math.pow`. +- Use `for-of` loops when you need the index to extract an item from the iterated array. +- Use `node:assert/strict` over `node:assert`. +- Use the `node:` protocol for Node.js builtin modules. +- Use Number properties instead of global ones. +- Use assignment operator shorthand where possible. +- Use function types instead of object types with call signatures. +- Use template literals over string concatenation. +- Use `new` when throwing an error. +- Don't throw non-Error values. +- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. +- Use standard constants instead of approximated literals. +- Don't assign values in expressions. +- Don't use async functions as Promise executors. +- Don't reassign exceptions in catch clauses. +- Don't reassign class members. +- Don't compare against -0. +- Don't use labeled statements that aren't loops. +- Don't use void type outside of generic or return types. +- Don't use console. +- Don't use control characters and escape sequences that match control characters in regular expression literals. +- Don't use debugger. +- Don't assign directly to document.cookie. +- Use `===` and `!==`. +- Don't use duplicate case labels. +- Don't use duplicate class members. +- Don't use duplicate conditions in if-else-if chains. +- Don't use two keys with the same name inside objects. +- Don't use duplicate function parameter names. +- Don't have duplicate hooks in describe blocks. +- Don't use empty block statements and static blocks. +- Don't let switch clauses fall through. +- Don't reassign function declarations. +- Don't allow assignments to native objects and read-only global variables. +- Use Number.isFinite instead of global isFinite. +- Use Number.isNaN instead of global isNaN. +- Don't assign to imported bindings. +- Don't use irregular whitespace characters. +- Don't use labels that share a name with a variable. +- Don't use characters made with multiple code points in character class syntax. +- Make sure to use new and constructor properly. +- Don't use shorthand assign when the variable appears on both sides. +- Don't use octal escape sequences in string literals. +- Don't use Object.prototype builtins directly. +- Don't redeclare variables, functions, classes, and types in the same scope. +- Don't have redundant "use strict". +- Don't compare things where both sides are exactly the same. +- Don't let identifiers shadow restricted names. +- Don't use sparse arrays (arrays with holes). +- Don't use template literal placeholder syntax in regular strings. +- Don't use the then property. +- Don't use unsafe negation. +- Don't use var. +- Don't use with statements in non-strict contexts. +- Make sure async functions actually use await. +- Make sure default clauses in switch statements come last. +- Make sure to pass a message value when creating a built-in error. +- Make sure get methods always return a value. +- Use a recommended display strategy with Google Fonts. +- Make sure for-in loops include an if statement. +- Use Array.isArray() instead of instanceof Array. +- Make sure to use the digits argument with Number#toFixed(). +- Make sure to use the "use strict" directive in script files. + +### Next.js Specific Rules + +- Don't use `` elements in Next.js projects. +- Don't use `` elements in Next.js projects. +- Don't import next/document outside of pages/\_document.jsx in Next.js projects. +- Don't use the next/head module in pages/\_document.js on Next.js projects. + +### Testing Best Practices + +- Don't use export or module.exports in test files. +- Don't use focused tests. +- Make sure the assertion function, like expect, is placed inside an it() function call. +- Don't use disabled tests. + +## Common Tasks + +- `npx ultracite init` - Initialize Ultracite in your project +- `npx ultracite format` - Format and fix code automatically +- `npx ultracite lint` - Check for issues without fixing + +## Example: Error Handling + +```typescript +// ✅ Good: Comprehensive error handling +try { + const result = await fetchData(); + return { success: true, data: result }; +} catch (error) { + console.error("API call failed:", error); + return { success: false, error: error.message }; +} + +// ❌ Bad: Swallowing errors +try { + return await fetchData(); +} catch (e) { + console.log(e); +} +``` diff --git a/.gitignore b/.gitignore index 2909d741..f5d4d26e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ bun.lockb target/ -.cargo-tools/ \ No newline at end of file +.cargo-tools/ + +rust-migration-notes.md \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..13be308b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +.next +node_modules +dist +build +target +rust/wasm/pkg diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..c9590876 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "useTabs": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 187722fb..2fc093b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,14 +7,7 @@ "editor.formatOnSave": true, "editor.formatOnPaste": true, "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit", - "source.organizeImports.biome": "explicit" + "source.fixAll.eslint": "explicit" }, - "emmet.showExpandedAbbreviation": "never", - "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - } + "emmet.showExpandedAbbreviation": "never" } diff --git a/AGENTS.md b/AGENTS.md index d326ac5d..cd59a85c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,17 +21,3 @@ Each app is a frontend that calls into Rust. Logic is never duplicated between a - Read components before using them. They may already apply classes, which affects what you need to pass and how to override them. -### TypeScript - -Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter: - -```tsx -// ❌ meaning depends on order; the shape can't evolve without touching every caller -function formatTime(seconds: number, fps: number) { ... } - -// ✅ each argument names itself; fields can be added, reordered, or made optional freely -function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... } -``` - -The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply. - diff --git a/Cargo.lock b/Cargo.lock index f3bd3c00..04c00a6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3820,7 +3820,7 @@ dependencies = [ [[package]] name = "opencut-wasm" -version = "0.2.9" +version = "0.2.10" dependencies = [ "bridge", "compositor", diff --git a/LICENSE b/LICENSE index e2c5ff28..3df4f89f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ -Copyright 2025 OpenCut +Copyright 2025-2026 OpenCut Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 3cb67abf..af46df56 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,167 @@ - - - - - -
- OpenCut Logo - -

OpenCut

-

A free, open-source video editor for web, desktop, and mobile.

-
- -## Sponsors - -Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software. - - - Vercel OSS Program - - - - Powered by fal.ai - - -## Why? - -- **Privacy**: Your videos stay on your device -- **Free features**: Most basic CapCut features are now paywalled -- **Simple**: People want editors that are easy to use - CapCut proved that - -## Project Structure - -- `apps/web/`: Next.js web application -- `apps/desktop/`: Native desktop app built with GPUI (in progress) -- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript. -- `docs/`: Architecture and subsystem documentation - -## Getting Started - -### Prerequisites - -- [Bun](https://bun.sh/docs/installation) -- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) - -> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it. - -### Setup - -1. Fork and clone the repository - -2. Copy the environment file: - - ```bash - # Unix/Linux/Mac - cp apps/web/.env.example apps/web/.env.local - - # Windows PowerShell - Copy-Item apps/web/.env.example apps/web/.env.local - ``` - -3. Start the database and Redis: - - ```bash - docker compose up -d db redis serverless-redis-http - ``` - -4. Install dependencies and start the dev server: - - ```bash - bun install - bun dev:web - ``` - -The application will be available at [http://localhost:3000](http://localhost:3000). - -The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box. - -### Desktop setup - -Desktop is opt-in. If you're only working on the web app, skip this entirely. - -If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies. - -### Local WASM development - -Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package. - -**Prerequisites** — install these once before anything else: - -```bash -# Rust toolchain -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - -# build the WASM package -cargo install wasm-pack - -# reruns the build on file changes, used by bun dev:wasm -cargo install cargo-watch -``` - -1. Build the package once from the repo root: - - ```bash - bun run build:wasm - ``` - -2. Register the generated package for linking: - - ```bash - cd rust/wasm/pkg - bun link - ``` - -3. Link `apps/web` to the local package: - - ```bash - cd apps/web - bun link opencut-wasm - ``` - -4. Rebuild on changes while you work: - - ```bash - bun dev:wasm - ``` - -To switch `apps/web` back to the published package, run: - -```bash -cd apps/web -bun add opencut-wasm -``` - -### Self-Hosting with Docker - -To run everything (including a production build of the app) in Docker: - -```bash -docker compose up -d -``` - -The app will be available at [http://localhost:3100](http://localhost:3100). - -## Contributing - -We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively. - -**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel. - -**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach. - -See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance. - -**Quick start for contributors:** - -- Fork the repo and clone locally -- Follow the setup instructions in CONTRIBUTING.md -- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup -- Create a feature branch and submit a PR - -## License - -[MIT LICENSE](LICENSE) - ---- - -![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date) + + + + + +
+ OpenCut Logo + +

OpenCut

+

A free, open-source video editor for web, desktop, and mobile.

+
+ +## Sponsors + +Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software. + + + Vercel OSS Program + + + + Powered by fal.ai + + +## Why? + +- **Privacy**: Your videos stay on your device +- **Free features**: Most basic CapCut features are now paywalled +- **Simple**: People want editors that are easy to use - CapCut proved that + +## Project Structure + +- `apps/web/`: Next.js web application +- `apps/desktop/`: Native desktop app built with GPUI (in progress) +- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript. +- `docs/`: Architecture and subsystem documentation + +## Getting Started + +### Prerequisites + +- [Bun](https://bun.sh/docs/installation) +- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) + +> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it. + +### Setup + +1. Fork and clone the repository + +2. Copy the environment file: + + ```bash + # Unix/Linux/Mac + cp apps/web/.env.example apps/web/.env.local + + # Windows PowerShell + Copy-Item apps/web/.env.example apps/web/.env.local + ``` + +3. Start the database and Redis: + + ```bash + docker compose up -d db redis serverless-redis-http + ``` + +4. Install dependencies and start the dev server: + + ```bash + bun install + bun dev:web + ``` + +The application will be available at [http://localhost:3000](http://localhost:3000). + +The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box. + +### Desktop setup + +Desktop is opt-in. If you're only working on the web app, skip this entirely. + +If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies. + +### Local WASM development + +Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package. + +**Prerequisites** — install these once before anything else: + +```bash +# Rust toolchain +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# build the WASM package +cargo install wasm-pack + +# reruns the build on file changes, used by bun dev:wasm +cargo install cargo-watch +``` + +1. Build the package once from the repo root: + + ```bash + bun run build:wasm + ``` + +2. Register the generated package for linking: + + ```bash + cd rust/wasm/pkg + bun link + ``` + +3. Link `apps/web` to the local package: + + ```bash + cd apps/web + bun link opencut-wasm + ``` + +4. Rebuild on changes while you work: + + ```bash + bun dev:wasm + ``` + +To switch `apps/web` back to the published package, run: + +```bash +cd apps/web +bun add opencut-wasm +``` + +### Self-Hosting with Docker + +To run everything (including a production build of the app) in Docker: + +```bash +docker compose up -d +``` + +The app will be available at [http://localhost:3100](http://localhost:3100). + +## Contributing + +We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively. + +**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel. + +**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach. + +See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance. + +**Quick start for contributors:** + +- Fork the repo and clone locally +- Follow the setup instructions in CONTRIBUTING.md +- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup +- Create a feature branch and submit a PR + +## License + +[MIT LICENSE](LICENSE) + +--- + +![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date) diff --git a/apps/web/package.json b/apps/web/package.json index 0034ebad..90cd9fb5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,9 +9,9 @@ "start": "next start", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", - "lint": "biome check src/", - "lint:fix": "biome check src/ --write", - "format": "biome format src/ --write", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "format": "prettier src --write", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push:local": "cross-env NODE_ENV=development drizzle-kit push", @@ -52,7 +52,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.2.9", + "opencut-wasm": "^0.2.10", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", diff --git a/apps/web/src/actions/components/shortcuts-dialog.tsx b/apps/web/src/actions/components/shortcuts-dialog.tsx index cd9c0242..7f7d8a87 100644 --- a/apps/web/src/actions/components/shortcuts-dialog.tsx +++ b/apps/web/src/actions/components/shortcuts-dialog.tsx @@ -51,10 +51,10 @@ export function ShortcutsDialog({ const keyString = getKeybindingString(e); if (keyString) { - const conflict = validateKeybinding( - keyString, - recordingShortcut.action, - ); + const conflict = validateKeybinding({ + key: keyString, + action: recordingShortcut.action, + }); if (conflict) { toast.error( `Key "${keyString}" is already bound to "${conflict.existingAction}"`, @@ -68,7 +68,10 @@ export function ShortcutsDialog({ removeKeybinding(key); } - updateKeybinding(keyString, recordingShortcut.action); + updateKeybinding({ + key: keyString, + action: recordingShortcut.action, + }); setIsRecording(false); setRecordingShortcut(null); diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts index 6f0ba700..986510ec 100644 --- a/apps/web/src/actions/definitions.ts +++ b/apps/web/src/actions/definitions.ts @@ -1,7 +1,4 @@ -import type { - KeybindingConfig, - ShortcutKey, -} from "@/actions/keybinding"; +import type { ShortcutKey } from "@/actions/keybinding"; import type { TActionWithOptionalArgs } from "./types"; export type TActionCategory = @@ -155,33 +152,36 @@ export const ACTIONS = { export type TAction = keyof typeof ACTIONS; -const ACTION_DEFAULT_SHORTCUTS = { - "toggle-play": ["space", "k"], - "seek-forward": ["l"], - "seek-backward": ["j"], - "frame-step-forward": ["right"], - "frame-step-backward": ["left"], - "jump-forward": ["shift+right"], - "jump-backward": ["shift+left"], - "goto-start": ["home", "enter"], - "goto-end": ["end"], - split: ["s"], - "split-left": ["q"], - "split-right": ["w"], - "delete-selected": ["backspace", "delete"], - "copy-selected": ["ctrl+c"], - "paste-copied": ["ctrl+v"], - "toggle-snapping": ["n"], - "select-all": ["ctrl+a"], - "cancel-interaction": ["escape"], - "duplicate-selected": ["ctrl+d"], - undo: ["ctrl+z"], - redo: ["ctrl+shift+z", "ctrl+y"], -} as const satisfies Partial>; +const ACTION_DEFAULT_SHORTCUTS = [ + ["toggle-play", ["space", "k"]], + ["seek-forward", ["l"]], + ["seek-backward", ["j"]], + ["frame-step-forward", ["right"]], + ["frame-step-backward", ["left"]], + ["jump-forward", ["shift+right"]], + ["jump-backward", ["shift+left"]], + ["goto-start", ["home", "enter"]], + ["goto-end", ["end"]], + ["split", ["s"]], + ["split-left", ["q"]], + ["split-right", ["w"]], + ["delete-selected", ["backspace", "delete"]], + ["copy-selected", ["ctrl+c"]], + ["paste-copied", ["ctrl+v"]], + ["toggle-snapping", ["n"]], + ["select-all", ["ctrl+a"]], + ["cancel-interaction", ["escape"]], + ["duplicate-selected", ["ctrl+d"]], + ["undo", ["ctrl+z"]], + ["redo", ["ctrl+shift+z", "ctrl+y"]], +] as const satisfies ReadonlyArray< + readonly [TActionWithOptionalArgs, readonly ShortcutKey[]] +>; -const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< - Record -> = ACTION_DEFAULT_SHORTCUTS; +const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map< + TAction, + readonly ShortcutKey[] +>(ACTION_DEFAULT_SHORTCUTS); export function getActionDefinition({ action, @@ -190,18 +190,19 @@ export function getActionDefinition({ }): TActionDefinition { return { ...ACTIONS[action], - defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action], + defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action), }; } -export function getDefaultShortcuts(): KeybindingConfig { - const shortcuts: KeybindingConfig = {}; +export function getDefaultShortcuts(): Map< + ShortcutKey, + TActionWithOptionalArgs +> { + const shortcuts = new Map(); - for (const [action, defaultShortcuts] of Object.entries( - ACTION_DEFAULT_SHORTCUTS, - ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) { + for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) { for (const shortcut of defaultShortcuts) { - shortcuts[shortcut] = action; + shortcuts.set(shortcut, action); } } diff --git a/apps/web/src/actions/keybinding.ts b/apps/web/src/actions/keybinding.ts index 65ef4538..aeec2fd6 100644 --- a/apps/web/src/actions/keybinding.ts +++ b/apps/web/src/actions/keybinding.ts @@ -1,79 +1,43 @@ -import type { TActionWithOptionalArgs } from "./types"; - -/** - * Alt is also regarded as macOS OPTION (⌥) key - * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) - */ -export type ModifierKeys = - | "ctrl" - | "alt" - | "shift" - | "ctrl+shift" - | "alt+shift" - | "ctrl+alt" - | "ctrl+alt+shift"; - -export type Key = - | "a" - | "b" - | "c" - | "d" - | "e" - | "f" - | "g" - | "h" - | "i" - | "j" - | "k" - | "l" - | "m" - | "n" - | "o" - | "p" - | "q" - | "r" - | "s" - | "t" - | "u" - | "v" - | "w" - | "x" - | "y" - | "z" - | "0" - | "1" - | "2" - | "3" - | "4" - | "5" - | "6" - | "7" - | "8" - | "9" - | "up" - | "down" - | "left" - | "right" - | "/" - | "?" - | "." - | "enter" - | "tab" - | "space" - | "escape" - | "esc" - | "backspace" - | "delete" - | "home" - | "end"; -/* eslint-enable */ - -export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; -// Singular keybindings (these will be disabled when an input-ish area has been focused) -export type SingleCharacterShortcutKey = `${Key}`; - -export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; - -export type KeybindingConfig = { - [key in ShortcutKey]?: TActionWithOptionalArgs; -}; +import type { TActionWithOptionalArgs } from "./types"; + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +export type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift"; + +const KEYS = [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", "z", + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + "up", "down", "left", "right", + "/", "?", ".", + "enter", "tab", "space", "escape", "esc", + "backspace", "delete", "home", "end", +] as const; + +export type Key = (typeof KEYS)[number]; + +const KEY_SET: ReadonlySet = new Set(KEYS); + +export function isKey(value: string): value is Key { + return KEY_SET.has(value); +} + +export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; +// Singular keybindings (these will be disabled when an input-ish area has been focused) +export type SingleCharacterShortcutKey = `${Key}`; + +export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; + +export type KeybindingConfig = { + [key in ShortcutKey]?: TActionWithOptionalArgs; +}; diff --git a/apps/web/src/actions/keybindings-store.ts b/apps/web/src/actions/keybindings-store.ts index c0a1a5e2..8f344487 100644 --- a/apps/web/src/actions/keybindings-store.ts +++ b/apps/web/src/actions/keybindings-store.ts @@ -6,11 +6,15 @@ import type { TActionWithOptionalArgs } from "@/actions"; import { getDefaultShortcuts } from "@/actions"; import { isTypableDOMElement } from "@/utils/browser"; import { isAppleDevice } from "@/utils/platform"; -import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; +import type { + Key, + KeybindingConfig, + ModifierKeys, + ShortcutKey, +} from "@/actions/keybinding"; +import { isKey } from "@/actions/keybinding"; import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; -const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); - export interface KeybindingConflict { key: ShortcutKey; existingAction: TActionWithOptionalArgs; @@ -18,38 +22,57 @@ export interface KeybindingConflict { } interface KeybindingsState { - keybindings: KeybindingConfig; + keybindings: Map; isCustomized: boolean; overlayDepth: number; openOverlayIds: string[]; isLoadingProject: boolean; isRecording: boolean; - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; + updateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => void; removeKeybinding: (key: ShortcutKey) => void; resetToDefaults: () => void; importKeybindings: (config: KeybindingConfig) => void; - exportKeybindings: () => KeybindingConfig; + exportKeybindings: () => Record; openOverlay: (overlayId: string) => void; closeOverlay: (overlayId: string) => void; setLoadingProject: (loading: boolean) => void; setIsRecording: (isRecording: boolean) => void; - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => KeybindingConflict | null; + validateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => KeybindingConflict | null; getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; } +type PersistedState = { + keybindings: Record; + isCustomized: boolean; +}; + function isDOMElement(element: EventTarget | null): element is HTMLElement { return element instanceof HTMLElement; } +function isPersistedState(value: unknown): value is PersistedState { + if (!value || typeof value !== "object") return false; + if (!("keybindings" in value) || !("isCustomized" in value)) return false; + const { keybindings, isCustomized } = value; + return ( + typeof keybindings === "object" && + keybindings !== null && + typeof isCustomized === "boolean" + ); +} + export const useKeybindingsStore = create()( persist( (set, get) => ({ - keybindings: { ...defaultKeybindings }, + keybindings: getDefaultShortcuts(), isCustomized: false, overlayDepth: 0, openOverlayIds: [], @@ -61,10 +84,9 @@ export const useKeybindingsStore = create()( const openOverlayIds = s.openOverlayIds.includes(overlayId) ? s.openOverlayIds : [...s.openOverlayIds, overlayId]; - const nextOverlayDepth = openOverlayIds.length; return { openOverlayIds, - overlayDepth: nextOverlayDepth, + overlayDepth: openOverlayIds.length, }; }), closeOverlay: (overlayId) => @@ -72,35 +94,32 @@ export const useKeybindingsStore = create()( const openOverlayIds = s.openOverlayIds.filter( (id) => id !== overlayId, ); - const nextOverlayDepth = openOverlayIds.length; return { openOverlayIds, - overlayDepth: nextOverlayDepth, + overlayDepth: openOverlayIds.length, }; }), setLoadingProject: (loading) => { set({ isLoadingProject: loading }); }, - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { + updateKeybinding: ({ key, action }) => { set((state) => { - const newKeybindings = { ...state.keybindings }; - newKeybindings[key] = action; - + const next = new Map(state.keybindings); + next.set(key, action); return { - keybindings: newKeybindings, + keybindings: next, isCustomized: true, }; }); }, - removeKeybinding: (key: ShortcutKey) => { + removeKeybinding: (key) => { set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; - + const next = new Map(state.keybindings); + next.delete(key); return { - keybindings: newKeybindings, + keybindings: next, isCustomized: true, }; }); @@ -108,34 +127,35 @@ export const useKeybindingsStore = create()( resetToDefaults: () => { set({ - keybindings: { ...defaultKeybindings }, + keybindings: getDefaultShortcuts(), isCustomized: false, }); }, - importKeybindings: (config: KeybindingConfig) => { - for (const [key] of Object.entries(config)) { + importKeybindings: (config) => { + const next = new Map(); + for (const [key, action] of Object.entries(config)) { if (typeof key !== "string" || key.length === 0) { throw new Error(`Invalid key format: ${key}`); } + if (action !== undefined) { + // Public type's keys are `ShortcutKey`; trust the caller's typing. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + next.set(key as ShortcutKey, action); + } } set({ - keybindings: { ...config }, + keybindings: next, isCustomized: true, }); }, exportKeybindings: () => { - return get().keybindings; + return Object.fromEntries(get().keybindings); }, - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; - + validateKeybinding: ({ key, action }) => { + const existingAction = get().keybindings.get(key); if (existingAction && existingAction !== action) { return { key, @@ -143,33 +163,45 @@ export const useKeybindingsStore = create()( newAction: action, }; } - return null; }, - setIsRecording: (isRecording: boolean) => { + setIsRecording: (isRecording) => { set({ isRecording }); }, - getKeybindingsForAction: (action: TActionWithOptionalArgs) => { - const { keybindings } = get(); - return Object.keys(keybindings).filter( - (key) => keybindings[key as ShortcutKey] === action, - ) as ShortcutKey[]; + getKeybindingsForAction: (action) => { + const result: ShortcutKey[] = []; + for (const [key, mapped] of get().keybindings) { + if (mapped === action) result.push(key); + } + return result; }, - getKeybindingString: (ev: KeyboardEvent) => { - return generateKeybindingString(ev) as ShortcutKey | null; - }, + getKeybindingString: (ev) => generateKeybindingString(ev), }), { name: "opencut-keybindings", version: CURRENT_VERSION, - partialize: (state) => ({ - keybindings: state.keybindings, + partialize: (state): PersistedState => ({ + keybindings: Object.fromEntries(state.keybindings), isCustomized: state.isCustomized, }), migrate: (persisted, version) => runMigrations({ state: persisted, fromVersion: version }), + merge: (persisted, current) => { + if (!isPersistedState(persisted)) return current; + const entries = Object.entries(persisted.keybindings); + // Persistence boundary: keys are normalized by the migration chain. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const typedEntries = entries as Array< + [ShortcutKey, TActionWithOptionalArgs] + >; + return { + ...current, + keybindings: new Map(typedEntries), + isCustomized: persisted.isCustomized, + }; + }, }, ), ); @@ -184,68 +216,59 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { if ( modifierKey === "shift" && isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) + isTypableDOMElement({ element: target }) ) { return null; } - return `${modifierKey}+${key}` as ShortcutKey; + return `${modifierKey}+${key}`; } - if ( - isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) - ) + if (isDOMElement(target) && isTypableDOMElement({ element: target })) { return null; + } - return `${key}` as ShortcutKey; + return key; } -function getPressedKey(ev: KeyboardEvent): string | null { - const key = (ev.key ?? "").toLowerCase(); +function getPressedKey(ev: KeyboardEvent): Key | null { + const raw = (ev.key ?? "").toLowerCase(); const code = ev.code ?? ""; - if (code === "Space" || key === " " || key === "spacebar" || key === "space") + if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space") return "space"; - if (key.startsWith("arrow")) return key.slice(5); - - if (key === "escape") return "escape"; - if (key === "tab") return "tab"; - if (key === "home") return "home"; - if (key === "end") return "end"; - if (key === "delete") return "delete"; - if (key === "backspace") return "backspace"; + if (raw === "arrowup") return "up"; + if (raw === "arrowdown") return "down"; + if (raw === "arrowleft") return "left"; + if (raw === "arrowright") return "right"; if (code.startsWith("Key")) { const letter = code.slice(3).toLowerCase(); - if (letter.length === 1 && letter >= "a" && letter <= "z") return letter; + if (isKey(letter)) return letter; } - // Use physical key position for AZERTY and other non-QWERTY layouts + // Use physical key position for AZERTY and other non-QWERTY layouts. if (code.startsWith("Digit")) { const digit = code.slice(5); - if (digit.length === 1 && digit >= "0" && digit <= "9") return digit; + if (isKey(digit)) return digit; } - const isDigit = key.length === 1 && key >= "0" && key <= "9"; - if (isDigit) return key; - - if (key === "/" || key === "." || key === "enter") return key; - + if (isKey(raw)) return raw; return null; } -function getActiveModifier(ev: KeyboardEvent): string | null { - const modifierKeys = { - ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, - alt: ev.altKey, - shift: ev.shiftKey, - }; +function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null { + const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey; + const alt = ev.altKey; + const shift = ev.shiftKey; - const activeModifier = Object.keys(modifierKeys) - .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) - .join("+"); - - return activeModifier === "" ? null : activeModifier; + if (ctrl && alt && shift) return "ctrl+alt+shift"; + if (ctrl && alt) return "ctrl+alt"; + if (ctrl && shift) return "ctrl+shift"; + if (alt && shift) return "alt+shift"; + if (ctrl) return "ctrl"; + if (alt) return "alt"; + if (shift) return "shift"; + return null; } diff --git a/apps/web/src/actions/keybindings/__tests__/persistence.test.ts b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts new file mode 100644 index 00000000..bcccc6a4 --- /dev/null +++ b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts @@ -0,0 +1,144 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { + decodePersistedKeybindingsState, + migratePersistedKeybindingsState, + parseImportedKeybindings, + serializeKeybindingsState, +} from "../persistence"; + +describe("keybinding persistence", () => { + let warnSpy: ReturnType; + let originalWarn: typeof console.warn; + + beforeEach(() => { + originalWarn = console.warn; + warnSpy = mock(() => {}); + console.warn = warnSpy; + }); + + afterEach(() => { + console.warn = originalWarn; + }); + + test("migrates legacy persisted keybindings before decoding them", () => { + const migrated = migratePersistedKeybindingsState({ + state: { + keybindings: { + s: "split-selected", + "ctrl+v": "paste-selected", + }, + isCustomized: true, + }, + fromVersion: 2, + }); + + const decoded = decodePersistedKeybindingsState({ state: migrated }); + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected migrated keybindings to decode"); + + expect(decoded.isCustomized).toBe(true); + expect(decoded.keybindings.get("s")).toBe("split"); + expect(decoded.keybindings.get("ctrl+v")).toBe("paste-copied"); + expect(decoded.keybindings.get("escape")).toBe("cancel-interaction"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("filters invalid persisted entries at the boundary and warns", () => { + const decoded = decodePersistedKeybindingsState({ + state: { + keybindings: { + space: "toggle-play", + "shift+bogus": "toggle-play", + "ctrl+v": "not-an-action", + }, + isCustomized: false, + }, + }); + + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected persisted keybindings to decode"); + + expect(Array.from(decoded.keybindings.entries())).toEqual([ + ["space", "toggle-play"], + ]); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test("returns null and warns when persisted shape is unrecognizable", () => { + const decoded = decodePersistedKeybindingsState({ state: "garbage" }); + expect(decoded).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test("round-trips actions that have no default shortcut", () => { + // `stop-playback` is a valid `TActionWithOptionalArgs` but is not in the + // defaults table; the validator must still accept it. + const serialized = serializeKeybindingsState({ + keybindings: new Map([ + ["space", "toggle-play"], + ["x", "stop-playback"], + ["b", "toggle-bookmark"], + ]), + isCustomized: true, + }); + + const decoded = decodePersistedKeybindingsState({ state: serialized }); + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected round-tripped keybindings"); + + expect(decoded.keybindings.get("space")).toBe("toggle-play"); + expect(decoded.keybindings.get("x")).toBe("stop-playback"); + expect(decoded.keybindings.get("b")).toBe("toggle-bookmark"); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe("parseImportedKeybindings", () => { + test("accepts a valid configuration", () => { + const result = parseImportedKeybindings({ + config: { + space: "toggle-play", + x: "stop-playback", + }, + }); + + expect(result.get("space")).toBe("toggle-play"); + expect(result.get("x")).toBe("stop-playback"); + }); + + test("throws on non-object input", () => { + expect(() => parseImportedKeybindings({ config: null })).toThrow( + /JSON object/, + ); + expect(() => parseImportedKeybindings({ config: [] })).toThrow( + /JSON object/, + ); + }); + + test("throws on non-string action values", () => { + expect(() => + parseImportedKeybindings({ config: { space: 42 } }), + ).toThrow(/expected string/); + }); + + test("throws on invalid shortcut keys", () => { + expect(() => + parseImportedKeybindings({ + config: { "shift+bogus": "toggle-play" }, + }), + ).toThrow(/shift\+bogus/); + }); + + test("throws on invalid actions", () => { + expect(() => + parseImportedKeybindings({ config: { space: "not-an-action" } }), + ).toThrow(/not-an-action/); + }); +}); diff --git a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts index 0171c5a5..81fd86a7 100644 --- a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts +++ b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts @@ -1,13 +1,8 @@ -import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; -import type { TActionWithOptionalArgs } from "@/actions"; - -interface V2State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v2ToV3({ state }: { state: unknown }): unknown { - const v2 = state as V2State; + const v2 = getPersistedKeybindingsState({ state }); + if (!v2) return state; const renames: Record = { "split-selected": "split", @@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown { const migrated = { ...v2.keybindings }; for (const [key, action] of Object.entries(migrated)) { - if (action && renames[action]) { - migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; + const renamedAction = action ? renames[action] : undefined; + if (renamedAction) { + migrated[key] = renamedAction; } } diff --git a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts index 8541071b..3724f61c 100644 --- a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts +++ b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts @@ -1,14 +1,8 @@ -import type { TActionWithOptionalArgs } from "@/actions"; -import type { ShortcutKey } from "@/actions/keybinding"; -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V3State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v3ToV4({ state }: { state: unknown }): unknown { - const v3 = state as V3State; + const v3 = getPersistedKeybindingsState({ state }); + if (!v3) return state; const renames: Record = { "paste-selected": "paste-copied", @@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown { const migrated = { ...v3.keybindings }; for (const [key, action] of Object.entries(migrated)) { - if (action && renames[action]) { - migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; + const renamedAction = action ? renames[action] : undefined; + if (renamedAction) { + migrated[key] = renamedAction; } } diff --git a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts index f6937c39..bb60b044 100644 --- a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts +++ b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts @@ -1,12 +1,8 @@ -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V4State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v4ToV5({ state }: { state: unknown }): unknown { - const v4 = state as V4State; + const v4 = getPersistedKeybindingsState({ state }); + if (!v4) return state; const keybindings = { ...v4.keybindings }; if (!keybindings.escape) { diff --git a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts index 09b44307..cb572aac 100644 --- a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts +++ b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts @@ -1,12 +1,8 @@ -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V5State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v5ToV6({ state }: { state: unknown }): unknown { - const v5 = state as V5State; + const v5 = getPersistedKeybindingsState({ state }); + if (!v5) return state; const keybindings = { ...v5.keybindings }; if (keybindings.escape === "deselect-all") { diff --git a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts index 46a99ac1..9c05db23 100644 --- a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts +++ b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts @@ -1,16 +1,12 @@ -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V6State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v6ToV7({ state }: { state: unknown }): unknown { - const v6 = state as V6State; + const v6 = getPersistedKeybindingsState({ state }); + if (!v6) return state; const keybindings = { ...v6.keybindings }; - for (const key of Object.keys(keybindings) as Array) { - if (keybindings[key] === ("split-element" as never)) { + for (const [key, action] of Object.entries(keybindings)) { + if (action === "split-element") { keybindings[key] = "split"; } } diff --git a/apps/web/src/actions/keybindings/persisted-state.ts b/apps/web/src/actions/keybindings/persisted-state.ts new file mode 100644 index 00000000..57d83f6d --- /dev/null +++ b/apps/web/src/actions/keybindings/persisted-state.ts @@ -0,0 +1,37 @@ +export type PersistedKeybindingConfig = Record; + +export interface PersistedKeybindingsState { + keybindings: PersistedKeybindingConfig; + isCustomized: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function getPersistedKeybindingsState({ + state, +}: { + state: unknown; +}): PersistedKeybindingsState | null { + if (!isRecord(state)) return null; + + const { keybindings, isCustomized } = state; + if (!isRecord(keybindings) || typeof isCustomized !== "boolean") { + return null; + } + + const normalizedKeybindings: PersistedKeybindingConfig = {}; + for (const [key, action] of Object.entries(keybindings)) { + if (action !== undefined && typeof action !== "string") { + return null; + } + + normalizedKeybindings[key] = action; + } + + return { + keybindings: normalizedKeybindings, + isCustomized, + }; +} diff --git a/apps/web/src/actions/keybindings/persistence.ts b/apps/web/src/actions/keybindings/persistence.ts new file mode 100644 index 00000000..ced44bd0 --- /dev/null +++ b/apps/web/src/actions/keybindings/persistence.ts @@ -0,0 +1,119 @@ +import type { ShortcutKey } from "@/actions/keybinding"; +import { isShortcutKey } from "@/actions/keybinding"; +import type { TActionWithOptionalArgs } from "@/actions"; +import { isActionWithOptionalArgs } from "@/actions"; +import { runMigrations } from "./migrations"; +import { + getPersistedKeybindingsState, + type PersistedKeybindingsState, +} from "./persisted-state"; + +export interface DecodedKeybindingsState { + keybindings: Map; + 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(); + 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 { + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw new Error("Imported keybindings must be a JSON object"); + } + + const result = new Map(); + for (const [key, action] of Object.entries(config)) { + if (action === undefined) continue; + if (typeof action !== "string") { + throw new Error( + `Invalid action for "${key}": expected string, got ${typeof action}`, + ); + } + if (!isShortcutKey(key)) { + throw new Error(`Invalid shortcut key: ${JSON.stringify(key)}`); + } + if (!isActionWithOptionalArgs(action)) { + throw new Error( + `Invalid action for "${key}": ${JSON.stringify(action)}`, + ); + } + result.set(key, action); + } + return result; +} diff --git a/apps/web/src/actions/registry.ts b/apps/web/src/actions/registry.ts index e69b3b9f..ece14eaf 100644 --- a/apps/web/src/actions/registry.ts +++ b/apps/web/src/actions/registry.ts @@ -11,6 +11,7 @@ import type { type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void; const boundActions: Partial> = {}; +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). export function bindAction( action: A, handler: TActionFunc, @@ -24,6 +25,7 @@ export function bindAction( } } +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). export function unbindAction( action: A, handler: TActionFunc, @@ -52,6 +54,7 @@ type InvokeActionFunc = { ): void; }; +// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger. export const invokeAction: InvokeActionFunc = ( action: A, args?: TArgOfAction, diff --git a/apps/web/src/actions/types.ts b/apps/web/src/actions/types.ts index 41258c7b..38627b95 100644 --- a/apps/web/src/actions/types.ts +++ b/apps/web/src/actions/types.ts @@ -1,44 +1,44 @@ -import type { MutableRefObject } from "react"; -import type { TAction } from "./definitions"; - -export type { TAction }; - -export type TActionArgsMap = { - "seek-forward": { seconds: number } | undefined; - "seek-backward": { seconds: number } | undefined; - "jump-forward": { seconds: number } | undefined; - "jump-backward": { seconds: number } | undefined; - "remove-media-asset": { projectId: string; assetId: string }; - "remove-media-assets": { projectId: string; assetIds: string[] }; -}; - -type TKeysWithValueUndefined = { - [K in keyof T]: undefined extends T[K] ? K : never; -}[keyof T]; - -export type TActionWithArgs = keyof TActionArgsMap; - -export type TActionWithOptionalArgs = - | TActionWithNoArgs - | TKeysWithValueUndefined; - -export type TActionWithNoArgs = Exclude; - -export type TArgOfAction = A extends TActionWithArgs - ? TActionArgsMap[A] - : undefined; - -export type TActionFunc = A extends TActionWithArgs - ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void - : (_?: undefined, trigger?: TInvocationTrigger) => void; - -export type TInvocationTrigger = "keypress" | "mouseclick"; - -export type TBoundActionList = { - [A in TAction]?: Array>; -}; - -export type TActionHandlerOptions = - | MutableRefObject - | boolean - | undefined; +import type { MutableRefObject } from "react"; +import type { TAction } from "./definitions"; + +export type { TAction }; + +export type TActionArgsMap = { + "seek-forward": { seconds: number } | undefined; + "seek-backward": { seconds: number } | undefined; + "jump-forward": { seconds: number } | undefined; + "jump-backward": { seconds: number } | undefined; + "remove-media-asset": { projectId: string; assetId: string }; + "remove-media-assets": { projectId: string; assetIds: string[] }; +}; + +type TKeysWithValueUndefined = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +export type TActionWithArgs = keyof TActionArgsMap; + +export type TActionWithOptionalArgs = + | TActionWithNoArgs + | TKeysWithValueUndefined; + +export type TActionWithNoArgs = Exclude; + +export type TArgOfAction = A extends TActionWithArgs + ? TActionArgsMap[A] + : undefined; + +export type TActionFunc = A extends TActionWithArgs + ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void + : (_?: undefined, trigger?: TInvocationTrigger) => void; + +export type TInvocationTrigger = "keypress" | "mouseclick"; + +export type TBoundActionList = { + [A in TAction]?: Array>; +}; + +export type TActionHandlerOptions = + | MutableRefObject + | boolean + | undefined; diff --git a/apps/web/src/actions/use-action-handler.ts b/apps/web/src/actions/use-action-handler.ts index a8bb0054..d4a37b50 100644 --- a/apps/web/src/actions/use-action-handler.ts +++ b/apps/web/src/actions/use-action-handler.ts @@ -8,6 +8,7 @@ import type { } from "@/actions"; import { bindAction, unbindAction } from "@/actions"; +// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive). export function useActionHandler( action: A, handler: TActionFunc, diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index e62a8ce0..e5d3a23b 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -1,11 +1,20 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { useTimelineStore } from "@/timeline/timeline-store"; import { useActionHandler } from "@/actions/use-action-handler"; import { useEditor } from "@/editor/use-editor"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + mediaTimeFromSeconds, + minMediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; import { getElementsAtTime, hasMediaId } from "@/timeline"; import { cancelInteraction } from "@/editor/cancel-interaction"; @@ -16,6 +25,7 @@ import { clearActiveScope, type ScopeEntry, } from "@/selection/scope"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; export function useEditorActions() { const editor = useEditor(); @@ -27,47 +37,34 @@ export function useEditorActions() { const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); - const hasTimelineSelectionRef = useRef(false); - const clearTimelineSelectionRef = useRef(() => {}); - const clearTimelineActiveSelectionRef = useRef(() => {}); - const timelineScopeRef = useRef(null); const hasTimelineSelection = selectedElements.length > 0 || selectedKeyframes.length > 0 || selectedMaskPointSelection !== null; - - hasTimelineSelectionRef.current = hasTimelineSelection; - clearTimelineSelectionRef.current = () => { + const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection); + const clearTimelineSelectionRef = useCommittedRef(() => { editor.selection.clearSelection(); - }; - clearTimelineActiveSelectionRef.current = () => { + }); + const clearTimelineActiveSelectionRef = useCommittedRef(() => { editor.selection.clearMostSpecificSelection(); - }; - - if (!timelineScopeRef.current) { - timelineScopeRef.current = { - hasSelection: () => hasTimelineSelectionRef.current, - clear: () => { - clearTimelineSelectionRef.current(); - }, - clearActive: () => { - clearTimelineActiveSelectionRef.current(); - }, - }; - } + }); + const [timelineScope] = useState(() => ({ + hasSelection: () => hasTimelineSelectionRef.current, + clear: () => { + clearTimelineSelectionRef.current(); + }, + clearActive: () => { + clearTimelineActiveSelectionRef.current(); + }, + })); useEffect(() => { if (!hasTimelineSelection) { return; } - const timelineScope = timelineScopeRef.current; - if (!timelineScope) { - return; - } - return activateScope({ entry: timelineScope }); - }, [hasTimelineSelection]); + }, [hasTimelineSelection, timelineScope]); useActionHandler( "toggle-play", @@ -83,7 +80,7 @@ export function useEditorActions() { if (editor.playback.getIsPlaying()) { editor.playback.toggle(); } - editor.playback.seek({ time: 0 }); + editor.playback.seek({ time: ZERO_MEDIA_TIME }); }, undefined, ); @@ -92,11 +89,15 @@ export function useEditorActions() { "seek-forward", (args) => { const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -106,8 +107,15 @@ export function useEditorActions() { "seek-backward", (args) => { const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -117,15 +125,20 @@ export function useEditorActions() { "frame-step-forward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); - editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + ticksPerFrame, + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, ), }); + editor.playback.seek({ + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), + }); }, undefined, ); @@ -134,11 +147,19 @@ export function useEditorActions() { "frame-step-backward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ), + }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), }); }, undefined, @@ -148,11 +169,15 @@ export function useEditorActions() { "jump-forward", (args) => { const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -162,8 +187,15 @@ export function useEditorActions() { "jump-backward", (args) => { const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -172,7 +204,7 @@ export function useEditorActions() { useActionHandler( "goto-start", () => { - editor.playback.seek({ time: 0 }); + editor.playback.seek({ time: ZERO_MEDIA_TIME }); }, undefined, ); @@ -273,7 +305,7 @@ export function useEditorActions() { if (!selectedMaskPointSelection) { return; } - editor.timeline.deleteCustomMaskPoints({ + editor.timeline.deleteFreeformPathMaskPoints({ trackId: selectedMaskPointSelection.trackId, elementId: selectedMaskPointSelection.elementId, maskId: selectedMaskPointSelection.maskId, diff --git a/apps/web/src/actions/use-keybindings.ts b/apps/web/src/actions/use-keybindings.ts index c129795c..e9d78982 100644 --- a/apps/web/src/actions/use-keybindings.ts +++ b/apps/web/src/actions/use-keybindings.ts @@ -33,7 +33,7 @@ export function useKeybindingsListener() { const isTextInput = activeElement instanceof HTMLElement && isTypableDOMElement({ element: activeElement }); - const boundAction = binding ? keybindings[binding] : undefined; + const boundAction = binding ? keybindings.get(binding) : undefined; if (normalizedKey === "escape" && isTextInput) { activeElement.blur(); diff --git a/apps/web/src/actions/use-keyboard-shortcuts-help.ts b/apps/web/src/actions/use-keyboard-shortcuts-help.ts index 7681e664..89a17887 100644 --- a/apps/web/src/actions/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/actions/use-keyboard-shortcuts-help.ts @@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() { const { keybindings } = useKeybindingsStore(); const shortcuts = useMemo(() => { - const result: KeyboardShortcut[] = []; - const actionToKeys: Partial> = {}; + const actionToKeys = new Map(); - for (const [key, action] of Object.entries(keybindings) as Array< - [string, TActionWithOptionalArgs | undefined] - >) { - if (action) { - if (!actionToKeys[action]) { - actionToKeys[action] = []; - } - actionToKeys[action].push(formatKey({ key })); + for (const [key, action] of keybindings) { + const existing = actionToKeys.get(action); + if (existing) { + existing.push(formatKey({ key })); + } else { + actionToKeys.set(action, [formatKey({ key })]); } } - for (const [action, keys] of Object.entries(actionToKeys) as Array< - [TActionWithOptionalArgs, string[]] - >) { + const result: KeyboardShortcut[] = []; + for (const [action, keys] of actionToKeys) { const actionDef = ACTIONS[action]; - if (!actionDef) { - continue; - } + if (!actionDef) continue; result.push({ id: action, keys, diff --git a/apps/web/src/animation/__tests__/animated-params.test.ts b/apps/web/src/animation/__tests__/animated-params.test.ts new file mode 100644 index 00000000..a47b0382 --- /dev/null +++ b/apps/web/src/animation/__tests__/animated-params.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "bun:test"; +import { + coerceParamValue, + getParamDefaultInterpolation, + getParamNumericRange, + getParamValueKind, +} from "@/params"; + +describe("animated params", () => { + test("snaps and clamps number params", () => { + expect( + coerceParamValue({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0, + min: 0, + max: 1, + step: 0.25, + }, + value: 0.62, + }), + ).toBe(0.5); + + expect( + coerceParamValue({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0, + min: 0, + max: 1, + step: 0.25, + }, + value: 1.2, + }), + ).toBe(1); + }); + + test("rejects NaN and non-number values for number params", () => { + const param = { + key: "intensity", + label: "Intensity", + type: "number" as const, + default: 0, + min: 0, + max: 1, + step: 0.25, + }; + expect(coerceParamValue({ param, value: Number.NaN })).toBeNull(); + expect(coerceParamValue({ param, value: "0.5" })).toBeNull(); + expect(coerceParamValue({ param, value: true })).toBeNull(); + }); + + test("passthrough with step <= 0 guard", () => { + expect( + coerceParamValue({ + param: { + key: "x", + label: "X", + type: "number", + default: 0, + min: 0, + step: 0, + }, + value: 0.123, + }), + ).toBe(0.123); + }); + + test("accepts valid select values", () => { + const param = { + key: "blend", + label: "Blend", + type: "select" as const, + default: "normal", + options: [ + { value: "normal", label: "Normal" }, + { value: "multiply", label: "Multiply" }, + ], + }; + expect(coerceParamValue({ param, value: "normal" })).toBe("normal"); + expect(coerceParamValue({ param, value: "multiply" })).toBe("multiply"); + }); + + test("rejects select values outside the allowed options", () => { + expect( + coerceParamValue({ + param: { + key: "blend", + label: "Blend", + type: "select", + default: "normal", + options: [ + { value: "normal", label: "Normal" }, + { value: "multiply", label: "Multiply" }, + ], + }, + value: "screen", + }), + ).toBeNull(); + }); + + test("rejects non-string select values", () => { + const param = { + key: "blend", + label: "Blend", + type: "select" as const, + default: "normal", + options: [{ value: "normal", label: "Normal" }], + }; + expect(coerceParamValue({ param, value: 42 })).toBeNull(); + expect(coerceParamValue({ param, value: null })).toBeNull(); + expect(coerceParamValue({ param, value: undefined })).toBeNull(); + }); + + test("boolean params accept booleans and reject other types", () => { + const param = { + key: "visible", + label: "Visible", + type: "boolean" as const, + default: true, + }; + expect(coerceParamValue({ param, value: true })).toBe(true); + expect(coerceParamValue({ param, value: false })).toBe(false); + expect(coerceParamValue({ param, value: 1 })).toBeNull(); + expect(coerceParamValue({ param, value: "true" })).toBeNull(); + }); + + test("color params accept strings and reject other types", () => { + const param = { + key: "fill", + label: "Fill", + type: "color" as const, + default: "#ffffff", + }; + expect(coerceParamValue({ param, value: "#ff0000" })).toBe("#ff0000"); + expect(coerceParamValue({ param, value: 0xff0000 })).toBeNull(); + expect(coerceParamValue({ param, value: null })).toBeNull(); + }); + + test("getAnimationParamValueKind maps param type to binding kind", () => { + expect( + getParamValueKind({ + param: { + key: "n", + label: "N", + type: "number", + default: 0, + min: 0, + step: 1, + }, + }), + ).toBe("number"); + expect( + getParamValueKind({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBe("color"); + expect( + getParamValueKind({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBe("discrete"); + expect( + getParamValueKind({ + param: { + key: "s", + label: "S", + type: "select", + default: "a", + options: [{ value: "a", label: "A" }], + }, + }), + ).toBe("discrete"); + }); + + test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => { + expect( + getParamDefaultInterpolation({ + param: { + key: "n", + label: "N", + type: "number", + default: 0, + min: 0, + step: 1, + }, + }), + ).toBe("linear"); + expect( + getParamDefaultInterpolation({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBe("linear"); + expect( + getParamDefaultInterpolation({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBe("hold"); + expect( + getParamDefaultInterpolation({ + param: { + key: "s", + label: "S", + type: "select", + default: "a", + options: [{ value: "a", label: "A" }], + }, + }), + ).toBe("hold"); + }); + + test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => { + expect( + getParamNumericRange({ + param: { + key: "intensity", + label: "Intensity", + type: "number", + default: 0.5, + min: 0, + max: 1, + step: 0.1, + }, + }), + ).toEqual({ min: 0, max: 1, step: 0.1 }); + expect( + getParamNumericRange({ + param: { key: "c", label: "C", type: "color", default: "#fff" }, + }), + ).toBeUndefined(); + expect( + getParamNumericRange({ + param: { key: "b", label: "B", type: "boolean", default: false }, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/animation/__tests__/binding-values.test.ts b/apps/web/src/animation/__tests__/binding-values.test.ts deleted file mode 100644 index a1f2010c..00000000 --- a/apps/web/src/animation/__tests__/binding-values.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - composeAnimationValue, - createAnimationBinding, -} from "@/animation/binding-values"; - -describe("binding values", () => { - test("formats composed animated colors as hex", () => { - const binding = createAnimationBinding({ - path: "color", - kind: "color", - }); - - expect( - composeAnimationValue({ - binding, - componentValues: { - r: 1, - g: 0, - b: 0, - a: 1, - }, - }), - ).toBe("#ff0000"); - }); -}); diff --git a/apps/web/src/animation/binding-values.ts b/apps/web/src/animation/binding-values.ts deleted file mode 100644 index a46aa9a6..00000000 --- a/apps/web/src/animation/binding-values.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { converter, formatHex, formatHex8, parse } from "culori"; -import type { - AnimationBindingComponent, - AnimationBindingOfKind, - AnimationBindingInstance, - AnimationBindingKind, - ColorAnimationBinding, - DiscreteAnimationBinding, - NumberAnimationBinding, - AnimationPath, - AnimationValue, - DiscreteValue, - Vector2AnimationBinding, - VectorValue, -} from "@/animation/types"; -import { clamp } from "@/utils/math"; - -interface LinearRgba { - r: number; - g: number; - b: number; - a: number; -} - -export type AnimationComponentValue = number | DiscreteValue; - -const toRgb = converter("rgb"); - -function srgbToLinear({ value }: { value: number }): number { - return value <= 0.04045 - ? value / 12.92 - : Math.pow((value + 0.055) / 1.055, 2.4); -} - -function linearToSrgb({ value }: { value: number }): number { - const clamped = clamp({ value, min: 0, max: 1 }); - return clamped <= 0.0031308 - ? clamped * 12.92 - : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -export function isVectorValue(value: unknown): value is VectorValue { - return isRecord(value) && typeof value.x === "number" && typeof value.y === "number"; -} - -export type EasingMode = "independent" | "shared"; - -/** - * Declares how easing curves apply to a binding's components. - * "shared" means all components always use the same curve (e.g. color — you never - * want to ease R independently from G/B/A). "independent" means each component - * can have its own curve. - */ -export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode { - return kind === "color" ? "shared" : "independent"; -} - -export function getBindingComponentKeys({ - kind, -}: { - kind: AnimationBindingKind; -}): string[] { - if (kind === "vector2") { - return ["x", "y"]; - } - - if (kind === "color") { - return ["r", "g", "b", "a"]; - } - - return ["value"]; -} - -export function buildBindingChannelId({ - path, - componentKey, -}: { - path: AnimationPath; - componentKey: string; -}): string { - return `${path}:${componentKey}`; -} - -function createBindingComponent({ - path, - key, -}: { - path: AnimationPath; - key: TKey; -}): AnimationBindingComponent { - return { - key, - channelId: buildBindingChannelId({ path, componentKey: key }), - }; -} - -function cloneBindingComponents({ - components, -}: { - components: AnimationBindingComponent[]; -}): AnimationBindingComponent[] { - return components.map((component) => ({ ...component })); -} - -const animationBindingFactories = { - color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({ - path, - kind: "color", - colorSpace: "srgb-linear", - components: [ - createBindingComponent({ path, key: "r" }), - createBindingComponent({ path, key: "g" }), - createBindingComponent({ path, key: "b" }), - createBindingComponent({ path, key: "a" }), - ], - }), - vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({ - path, - kind: "vector2", - components: [ - createBindingComponent({ path, key: "x" }), - createBindingComponent({ path, key: "y" }), - ], - }), - number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({ - path, - kind: "number", - components: [createBindingComponent({ path, key: "value" })], - }), - discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({ - path, - kind: "discrete", - components: [createBindingComponent({ path, key: "value" })], - }), -} satisfies { - [K in AnimationBindingKind]: ({ - path, - }: { - path: AnimationPath; - }) => AnimationBindingOfKind; -}; - -export function createAnimationBinding({ - path, - kind, -}: { - path: AnimationPath; - kind: TKind; -}): AnimationBindingOfKind; -export function createAnimationBinding({ - path, - kind, -}: { - path: AnimationPath; - kind: AnimationBindingKind; -}): AnimationBindingInstance { - return animationBindingFactories[kind]({ path }); -} - -const animationBindingCloners = { - color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - vector2: ({ - binding, - }: { - binding: Vector2AnimationBinding; - }): Vector2AnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - number: ({ - binding, - }: { - binding: NumberAnimationBinding; - }): NumberAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - discrete: ({ - binding, - }: { - binding: DiscreteAnimationBinding; - }): DiscreteAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), -} satisfies { - [K in AnimationBindingKind]: ({ - binding, - }: { - binding: AnimationBindingOfKind; - }) => AnimationBindingOfKind; -}; - -export function cloneAnimationBinding({ - binding, -}: { - binding: AnimationBindingOfKind; -}): AnimationBindingOfKind; -export function cloneAnimationBinding({ - binding, -}: { - binding: AnimationBindingInstance; -}): AnimationBindingInstance { - switch (binding.kind) { - case "color": - return animationBindingCloners.color({ binding }); - case "vector2": - return animationBindingCloners.vector2({ binding }); - case "number": - return animationBindingCloners.number({ binding }); - case "discrete": - return animationBindingCloners.discrete({ binding }); - } -} - -export function parseColorToLinearRgba({ - color, -}: { - color: string; -}): LinearRgba | null { - const parsed = parse(color); - const rgb = parsed ? toRgb(parsed) : null; - if (!rgb) { - return null; - } - - return { - r: srgbToLinear({ value: rgb.r ?? 0 }), - g: srgbToLinear({ value: rgb.g ?? 0 }), - b: srgbToLinear({ value: rgb.b ?? 0 }), - a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }), - }; -} - -export function formatLinearRgba({ - color, -}: { - color: LinearRgba; -}): string { - const rgb = { - mode: "rgb", - r: linearToSrgb({ value: color.r }), - g: linearToSrgb({ value: color.g }), - b: linearToSrgb({ value: color.b }), - alpha: clamp({ value: color.a, min: 0, max: 1 }), - } as const; - return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); -} - -export function decomposeAnimationValue({ - kind, - value, -}: { - kind: AnimationBindingKind; - value: AnimationValue; -}): Record | null { - if (kind === "number") { - return typeof value === "number" ? { value } : null; - } - - if (kind === "vector2") { - return isVectorValue(value) ? { x: value.x, y: value.y } : null; - } - - if (kind === "color") { - if (typeof value !== "string") { - return null; - } - const parsed = parseColorToLinearRgba({ color: value }); - if (!parsed) { - return null; - } - return { - r: parsed.r, - g: parsed.g, - b: parsed.b, - a: parsed.a, - }; - } - - return typeof value === "string" || typeof value === "boolean" - ? { value } - : null; -} - -export function composeAnimationValue({ - binding, - componentValues, -}: { - binding: AnimationBindingInstance; - componentValues: Record; -}): AnimationValue | null { - if (binding.kind === "number") { - const value = componentValues.value; - return typeof value === "number" ? value : null; - } - - if (binding.kind === "vector2") { - const x = componentValues.x; - const y = componentValues.y; - return typeof x === "number" && typeof y === "number" ? { x, y } : null; - } - - if (binding.kind === "color") { - const r = componentValues.r; - const g = componentValues.g; - const b = componentValues.b; - const a = componentValues.a; - if ( - typeof r !== "number" || - typeof g !== "number" || - typeof b !== "number" || - typeof a !== "number" - ) { - return null; - } - return formatLinearRgba({ - color: { - r, - g, - b, - a, - }, - }); - } - - const value = componentValues.value; - return typeof value === "string" || typeof value === "boolean" ? value : null; -} diff --git a/apps/web/src/animation/channel-data.ts b/apps/web/src/animation/channel-data.ts new file mode 100644 index 00000000..2fc08b5c --- /dev/null +++ b/apps/web/src/animation/channel-data.ts @@ -0,0 +1,57 @@ +import type { + AnimationChannel, + ChannelData, + CompositeChannelData, +} from "@/animation/types"; + +const LEGACY_ANIMATION_STORAGE_KEYS = new Set(["bindings", "channels"]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isLeafChannelData( + data: ChannelData | undefined, +): data is AnimationChannel { + return isRecord(data) && Array.isArray(data.keys); +} + +export function isCompositeChannelData( + data: ChannelData | undefined, +): data is CompositeChannelData { + return isRecord(data) && !Array.isArray(data.keys); +} + +export function getChannelsFromData({ + data, +}: { + data: ChannelData | undefined; +}): AnimationChannel[] { + if (isLeafChannelData(data)) { + return [data]; + } + if (!isCompositeChannelData(data)) { + return []; + } + return Object.values(data).filter(isLeafChannelData); +} + +export function getChannelEntriesFromData({ + data, +}: { + data: ChannelData | undefined; +}): Array<[string, AnimationChannel]> { + if (isLeafChannelData(data)) { + return [["value", data]]; + } + if (!isCompositeChannelData(data)) { + return []; + } + return Object.entries(data).flatMap(([componentKey, channel]) => + isLeafChannelData(channel) ? [[componentKey, channel]] : [], + ); +} + +export function isAnimationStorageKey({ key }: { key: string }): boolean { + return !LEGACY_ANIMATION_STORAGE_KEYS.has(key); +} diff --git a/apps/web/src/animation/curve-bridge.ts b/apps/web/src/animation/curve-bridge.ts index af411dd1..c947e7dc 100644 --- a/apps/web/src/animation/curve-bridge.ts +++ b/apps/web/src/animation/curve-bridge.ts @@ -7,6 +7,7 @@ import type { NormalizedCubicBezier, ScalarAnimationKey, } from "@/animation/types"; +import { roundMediaTime } from "@/wasm"; const VALUE_EPSILON = 1e-6; @@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({ return { rightHandle: { - dt: spanTime * x1, + dt: roundMediaTime({ time: spanTime * x1 }), dv: effectiveSpanValue * y1, }, leftHandle: { - dt: spanTime * (x2 - 1), + dt: roundMediaTime({ time: spanTime * (x2 - 1) }), dv: effectiveSpanValue * (y2 - 1), }, }; diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts index c635bb82..3def2109 100644 --- a/apps/web/src/animation/effect-param-channel.ts +++ b/apps/web/src/animation/effect-param-channel.ts @@ -1,9 +1,8 @@ -import type { ParamValues } from "@/params"; -import type { Effect } from "@/effects/types"; import type { ElementAnimations, EffectParamPath, } from "@/animation/types"; +import type { ParamValues } from "@/params"; import { removeElementKeyframe } from "./keyframes"; import { resolveAnimationPathValueAtTime } from "./resolve"; @@ -56,23 +55,26 @@ export function parseEffectParamPath({ } export function resolveEffectParamsAtTime({ - effect, + effectId, + params, animations, localTime, }: { - effect: Effect; + effectId: string; + params: ParamValues; animations: ElementAnimations | undefined; localTime: number; }): ParamValues { + const safeLocalTime = Math.max(0, localTime); const resolved: ParamValues = {}; - for (const [paramKey, staticValue] of Object.entries(effect.params)) { - const path = buildEffectParamPath({ effectId: effect.id, paramKey }); - resolved[paramKey] = animations?.bindings[path] + for (const [paramKey, staticValue] of Object.entries(params)) { + const path = buildEffectParamPath({ effectId, paramKey }); + resolved[paramKey] = animations?.[path] ? resolveAnimationPathValueAtTime({ animations, propertyPath: path, - localTime, + localTime: safeLocalTime, fallbackValue: staticValue, }) : staticValue; diff --git a/apps/web/src/animation/graph-channels.ts b/apps/web/src/animation/graph-channels.ts index d6482602..cef1397a 100644 --- a/apps/web/src/animation/graph-channels.ts +++ b/apps/web/src/animation/graph-channels.ts @@ -1,21 +1,35 @@ import type { - AnimationBindingInstance, AnimationPath, ElementAnimations, + ChannelData, ScalarAnimationChannel, ScalarGraphChannel, ScalarGraphKeyframeContext, } from "@/animation/types"; +import type { ChannelEasingMode } from "@/params"; +import { isCompositeChannelData, isLeafChannelData } from "./channel-data"; +import { isScalarChannel } from "./interpolation"; export interface EditableScalarChannels { - binding: AnimationBindingInstance; + easingMode: ChannelEasingMode; channels: ScalarGraphChannel[]; } function isScalarAnimationChannel( - channel: ElementAnimations["channels"][string], + channel: ChannelData | undefined, ): channel is ScalarAnimationChannel { - return channel?.kind === "scalar"; + return isLeafChannelData(channel) && isScalarChannel(channel); +} + +function getEasingModeForChannelData({ + data, +}: { + data: ChannelData | undefined; +}): ChannelEasingMode { + return isCompositeChannelData(data) && + ["r", "g", "b", "a"].every((componentKey) => componentKey in data) + ? "shared" + : "independent"; } export function getEditableScalarChannels({ @@ -25,13 +39,15 @@ export function getEditableScalarChannels({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; }): EditableScalarChannels | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + const data = animations?.[propertyPath]; + if (!data) { return null; } - const channels = binding.components.flatMap((component) => { - const channel = animations?.channels[component.channelId]; + const channelEntries = isLeafChannelData(data) + ? [["value", data] as const] + : Object.entries(data); + const channels = channelEntries.flatMap(([componentKey, channel]) => { if (!isScalarAnimationChannel(channel)) { return []; } @@ -39,14 +55,13 @@ export function getEditableScalarChannels({ return [ { propertyPath, - componentKey: component.key, - channelId: component.channelId, + componentKey, channel, } satisfies ScalarGraphChannel, ]; }); - return { binding, channels }; + return { easingMode: getEasingModeForChannelData({ data }), channels }; } export function getEditableScalarChannel({ diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts index ebf0f940..32f84714 100644 --- a/apps/web/src/animation/graphic-param-channel.ts +++ b/apps/web/src/animation/graphic-param-channel.ts @@ -2,11 +2,7 @@ import type { ElementAnimations, GraphicParamPath, } from "@/animation/types"; -import type { ParamValues } from "@/params"; -import { - getGraphicDefinition, - resolveGraphicParams, -} from "@/graphics"; +import type { ParamDefinition, ParamValues } from "@/params"; import { resolveAnimationPathValueAtTime } from "./resolve"; export const GRAPHIC_PARAM_PATH_PREFIX = "params."; @@ -39,33 +35,29 @@ export function parseGraphicParamPath({ } export function resolveGraphicParamsAtTime({ - element, + params, + definitions, + animations, localTime, }: { - element: { - definitionId: string; - params: ParamValues; - animations?: ElementAnimations; - }; + params: ParamValues; + definitions: ParamDefinition[]; + animations?: ElementAnimations; localTime: number; }): ParamValues { - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const baseParams = resolveGraphicParams(definition, element.params); - const resolved: ParamValues = { ...baseParams }; + const resolved: ParamValues = { ...params }; - for (const param of definition.params) { + for (const param of definitions) { const path = buildGraphicParamPath({ paramKey: param.key }); - if (!element.animations?.bindings[path]) { + if (!animations?.[path]) { continue; } resolved[param.key] = resolveAnimationPathValueAtTime({ - animations: element.animations, + animations, propertyPath: path, localTime: Math.max(0, localTime), - fallbackValue: baseParams[param.key] ?? param.default, + fallbackValue: params[param.key] ?? param.default, }); } diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts index 384f3d17..f3d8aca4 100644 --- a/apps/web/src/animation/index.ts +++ b/apps/web/src/animation/index.ts @@ -16,31 +16,14 @@ export { setChannel, splitAnimationsAtTime, updateScalarKeyframeCurve, - upsertElementKeyframe, upsertPathKeyframe, } from "./keyframes"; export { getElementLocalTime, resolveAnimationPathValueAtTime, - resolveColorAtTime, - resolveNumberAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, } from "./resolve"; -export { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getDefaultInterpolationForProperty, - getElementBaseValueForProperty, - isAnimationPropertyPath, - supportsAnimationProperty, - type AnimationPropertyDefinition, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; - export { getElementKeyframes, getKeyframeById, @@ -75,15 +58,6 @@ export { resolveEffectParamsAtTime, } from "./effect-param-channel"; -export { - isAnimationPath, - coerceAnimationValueForParam, - resolveAnimationTarget, - getParamValueKind, - getParamDefaultInterpolation, - type AnimationPathDescriptor, -} from "./target-resolver"; - export { getGroupKeyframesAtTime, hasGroupKeyframeAtTime, @@ -91,6 +65,8 @@ export { } from "./property-groups"; export { - type EasingMode, - getEasingModeForKind, -} from "./binding-values"; + isAnimationPath, + isAnimationPropertyPath, +} from "./path"; + +export type { NumericSpec } from "./types"; diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts index 277bfbb7..cdb8bcc9 100644 --- a/apps/web/src/animation/interpolation.ts +++ b/apps/web/src/animation/interpolation.ts @@ -1,20 +1,22 @@ import type { AnimationChannel, AnimationInterpolation, - AnimationValue, + Channel, DiscreteAnimationChannel, DiscreteValue, ScalarAnimationChannel, ScalarAnimationKey, ScalarSegmentType, } from "@/animation/types"; -import { clamp } from "@/utils/math"; +import type { ParamValue } from "@/params"; +import { mediaTime } from "@/wasm"; import { getBezierPoint, getDefaultLeftHandle, getDefaultRightHandle, solveBezierProgressForTime, } from "./bezier"; +import { clamp } from "@/utils/math"; function byTimeAscending({ leftTime, @@ -63,9 +65,13 @@ function normalizeRightHandle({ return undefined; } - const span = Math.max(1, rightKey.time - leftKey.time); + const span = mediaTime({ + ticks: Math.max(1, rightKey.time - leftKey.time), + }); return { - dt: Math.min(span, Math.max(0, handle.dt)), + dt: mediaTime({ + ticks: Math.min(span, Math.max(0, handle.dt)), + }), dv: handle.dv, }; } @@ -83,9 +89,13 @@ function normalizeLeftHandle({ return undefined; } - const span = Math.max(1, rightKey.time - leftKey.time); + const span = mediaTime({ + ticks: Math.max(1, rightKey.time - leftKey.time), + }); return { - dt: Math.max(-span, Math.min(0, handle.dt)), + dt: mediaTime({ + ticks: Math.max(-span, Math.min(0, handle.dt)), + }), dv: handle.dv, }; } @@ -102,7 +112,7 @@ function normalizeScalarKey({ }; } -function normalizeScalarChannel({ +export function normalizeScalarChannel({ channel, }: { channel: ScalarAnimationChannel; @@ -145,17 +155,11 @@ function normalizeScalarChannel({ }; } -export function normalizeChannel({ +export function normalizeDiscreteChannel({ channel, }: { - channel: TChannel; -}): TChannel { - if (channel.kind === "scalar") { - return normalizeScalarChannel({ - channel, - }) as TChannel; - } - + channel: DiscreteAnimationChannel; +}): DiscreteAnimationChannel { return { ...channel, keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => @@ -164,7 +168,39 @@ export function normalizeChannel({ rightTime: rightKeyframe.time, }), ), - } as TChannel; + }; +} + +export function isScalarChannel(channel: AnimationChannel): channel is ScalarAnimationChannel { + return ( + "extrapolation" in channel || + channel.keys.some((keyframe) => "segmentToNext" in keyframe) + ); +} + +export function normalizeChannel({ + channel, +}: { + channel: ScalarAnimationChannel; +}): ScalarAnimationChannel; +export function normalizeChannel({ + channel, +}: { + channel: DiscreteAnimationChannel; +}): DiscreteAnimationChannel; +export function normalizeChannel({ + channel, +}: { + channel: AnimationChannel; +}): AnimationChannel; +export function normalizeChannel({ + channel, +}: { + channel: AnimationChannel; +}): AnimationChannel { + return isScalarChannel(channel) + ? normalizeScalarChannel({ channel }) + : normalizeDiscreteChannel({ channel }); } function extrapolateScalarEdge({ @@ -207,7 +243,7 @@ export function getScalarChannelValueAtTime({ time, fallbackValue, }: { - channel: ScalarAnimationChannel | undefined; + channel: Channel | undefined; time: number; fallbackValue: number; }): number { @@ -215,9 +251,7 @@ export function getScalarChannelValueAtTime({ return fallbackValue; } - const normalizedChannel = normalizeChannel({ - channel, - }); + const normalizedChannel = normalizeScalarChannel({ channel }); const firstKey = normalizedChannel.keys[0]; const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; if (!firstKey || !lastKey) { @@ -319,7 +353,7 @@ export function getDiscreteChannelValueAtTime({ time, fallbackValue, }: { - channel: DiscreteAnimationChannel | undefined; + channel: Channel | undefined; time: number; fallbackValue: DiscreteValue; }): DiscreteValue { @@ -327,9 +361,7 @@ export function getDiscreteChannelValueAtTime({ return fallbackValue; } - const normalizedChannel = normalizeChannel({ - channel, - }); + const normalizedChannel = normalizeDiscreteChannel({ channel }); let currentValue = fallbackValue; for (const key of normalizedChannel.keys) { if (time < key.time) { @@ -340,6 +372,24 @@ export function getDiscreteChannelValueAtTime({ return currentValue; } +export function getChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: Channel | undefined; + time: number; + fallbackValue: number; +}): number; +export function getChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + fallbackValue: TValue; +}): TValue; export function getChannelValueAtTime({ channel, time, @@ -347,14 +397,14 @@ export function getChannelValueAtTime({ }: { channel: AnimationChannel | undefined; time: number; - fallbackValue: AnimationValue; -}): AnimationValue { + fallbackValue: ParamValue; +}): ParamValue { if (!channel || channel.keys.length === 0) { return fallbackValue; } - if (channel.kind === "scalar") { - return typeof fallbackValue === "number" + if (typeof fallbackValue === "number") { + return isScalarChannel(channel) ? getScalarChannelValueAtTime({ channel, time, @@ -368,7 +418,7 @@ export function getChannelValueAtTime({ } return getDiscreteChannelValueAtTime({ - channel, + channel: isScalarChannel(channel) ? undefined : channel, time, fallbackValue, }); diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts index 231aa5f2..95f7fcf0 100644 --- a/apps/web/src/animation/keyframe-query.ts +++ b/apps/web/src/animation/keyframe-query.ts @@ -1,75 +1,75 @@ import type { - AnimationBindingInstance, AnimationChannel, AnimationPath, + ChannelData, ElementAnimations, ElementKeyframe, } from "@/animation/types"; +import { formatLinearRgba } from "@/params"; import { - type AnimationComponentValue, - composeAnimationValue, -} from "./binding-values"; + getChannelEntriesFromData, + isAnimationStorageKey, +} from "./channel-data"; import { getChannelValueAtTime, getScalarSegmentInterpolation, + isScalarChannel, } from "./interpolation"; -import { isAnimationPath } from "./target-resolver"; +import { isAnimationPath } from "./path"; -function getBindingFallbackValue({ +function getChannelFallbackValue({ channel, }: { - channel: ElementAnimations["channels"][string]; + channel: AnimationChannel; }) { - if (!channel || channel.keys.length === 0) { - return channel?.kind === "discrete" ? false : 0; + if (channel.keys.length === 0) { + return isScalarChannel(channel) ? 0 : false; } return channel.keys[0].value; } -interface BindingKeyframeMatch { +interface ChannelKeyframeMatch { + componentKey: string; componentIndex: number; channel: AnimationChannel; keyframe: AnimationChannel["keys"][number]; } -function getBindingKeyframeMatches({ - animations, - binding, +function getChannelKeyframeMatches({ + data, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; -}): BindingKeyframeMatch[] { - return binding.components.flatMap((component, componentIndex) => { - const channel = animations.channels[component.channelId]; - if (!channel || channel.keys.length === 0) { - return []; - } + data: ChannelData | undefined; +}): ChannelKeyframeMatch[] { + return getChannelEntriesFromData({ data }).flatMap( + ([componentKey, channel], componentIndex) => { + if (channel.keys.length === 0) { + return []; + } - return channel.keys.map((keyframe) => ({ - componentIndex, - channel, - keyframe, - })); - }); + return channel.keys.map((keyframe) => ({ + componentKey, + componentIndex, + channel, + keyframe, + })); + }, + ); } -function getUniqueBindingKeyframeMatches({ - animations, - binding, +function getUniqueChannelKeyframeMatches({ + data, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; -}): BindingKeyframeMatch[] { - const sortedMatches = getBindingKeyframeMatches({ - animations, - binding, + data: ChannelData | undefined; +}): ChannelKeyframeMatch[] { + const sortedMatches = getChannelKeyframeMatches({ + data, }).sort( (leftMatch, rightMatch) => leftMatch.keyframe.time - rightMatch.keyframe.time || leftMatch.componentIndex - rightMatch.componentIndex, ); - const uniqueMatches: BindingKeyframeMatch[] = []; + const uniqueMatches: ChannelKeyframeMatch[] = []; for (const match of sortedMatches) { const previousMatch = uniqueMatches[uniqueMatches.length - 1]; @@ -92,11 +92,11 @@ function getUniqueBindingKeyframeMatches({ return uniqueMatches; } -function getPreferredBindingKeyframeMatch({ +function getPreferredChannelKeyframeMatch({ matches, }: { - matches: BindingKeyframeMatch[]; -}): BindingKeyframeMatch | null { + matches: ChannelKeyframeMatch[]; +}): ChannelKeyframeMatch | null { return ( matches.find((match) => match.componentIndex === 0) ?? matches[0] ?? @@ -104,35 +104,67 @@ function getPreferredBindingKeyframeMatch({ ); } -function getComposedBindingValueAtTime({ - animations, - binding, +function getChannelValue({ + channel, time, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; + channel: AnimationChannel; time: number; }) { - const componentValues = Object.fromEntries( - binding.components.map((component) => { - const channel = animations.channels[component.channelId]; - return [ - component.key, - getChannelValueAtTime({ - channel, - time, - fallbackValue: getBindingFallbackValue({ channel }), - }), - ]; - }), - ) as Record; - - return composeAnimationValue({ - binding, - componentValues, + const fallbackValue = getChannelFallbackValue({ channel }); + if (typeof fallbackValue === "number") { + return getChannelValueAtTime({ + channel: isScalarChannel(channel) ? channel : undefined, + time, + fallbackValue, + }); + } + return getChannelValueAtTime({ + channel: !isScalarChannel(channel) ? channel : undefined, + time, + fallbackValue, }); } +function getComposedChannelDataValueAtTime({ + data, + time, +}: { + data: ChannelData | undefined; + time: number; +}) { + const entries = getChannelEntriesFromData({ data }); + if (entries.length === 0) { + return null; + } + if (entries.length === 1 && entries[0]?.[0] === "value") { + return getChannelValue({ channel: entries[0][1], time }); + } + + const componentValues = Object.fromEntries( + entries.map(([componentKey, channel]) => [ + componentKey, + getChannelValue({ channel, time }), + ]), + ); + if ( + typeof componentValues.r === "number" && + typeof componentValues.g === "number" && + typeof componentValues.b === "number" && + typeof componentValues.a === "number" + ) { + return formatLinearRgba({ + color: { + r: componentValues.r, + g: componentValues.g, + b: componentValues.b, + a: componentValues.a, + }, + }); + } + return null; +} + function getKeyframeInterpolation({ channel, keyframe, @@ -140,25 +172,22 @@ function getKeyframeInterpolation({ channel: AnimationChannel; keyframe: AnimationChannel["keys"][number]; }) { - return channel.kind === "scalar" && "segmentToNext" in keyframe + return isScalarChannel(channel) && "segmentToNext" in keyframe ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) : "hold"; } function toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; + data: ChannelData | undefined; propertyPath: AnimationPath; - keyframeMatch: BindingKeyframeMatch; + keyframeMatch: ChannelKeyframeMatch; }): ElementKeyframe | null { - const value = getComposedBindingValueAtTime({ - animations, - binding, + const value = getComposedChannelDataValueAtTime({ + data, time: keyframeMatch.keyframe.time, }); if (value === null) { @@ -186,19 +215,19 @@ export function getElementKeyframes({ return []; } - return Object.entries(animations.bindings).flatMap( - ([propertyPath, binding]) => { - if (!binding || !isAnimationPath(propertyPath)) { + return Object.entries(animations).filter(([key]) => + isAnimationStorageKey({ key }), + ).flatMap( + ([propertyPath, data]) => { + if (!data || !isAnimationPath(propertyPath)) { return []; } - return getUniqueBindingKeyframeMatches({ - animations, - binding, + return getUniqueChannelKeyframeMatches({ + data, }).flatMap((keyframeMatch) => { const keyframe = toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }); @@ -219,13 +248,8 @@ export function hasKeyframesForPath({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; }): boolean { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return false; - } - - return binding.components.some((component) => - Boolean(animations?.channels[component.channelId]?.keys.length), + return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some( + ([, channel]) => channel.keys.length > 0, ); } @@ -238,24 +262,22 @@ export function getKeyframeAtTime({ propertyPath: AnimationPath; time: number; }): ElementKeyframe | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + const data = animations?.[propertyPath]; + if (!data) { return null; } - const keyframeMatch = getPreferredBindingKeyframeMatch({ - matches: getBindingKeyframeMatches({ - animations, - binding, - }).filter(({ keyframe }) => keyframe.time === time), + const keyframeMatch = getPreferredChannelKeyframeMatch({ + matches: getChannelKeyframeMatches({ data }).filter( + ({ keyframe }) => keyframe.time === time, + ), }); if (!keyframeMatch) { return null; } return toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }); @@ -270,24 +292,22 @@ export function getKeyframeById({ propertyPath: AnimationPath; keyframeId: string; }): ElementKeyframe | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + const data = animations?.[propertyPath]; + if (!data) { return null; } - const keyframeMatch = getPreferredBindingKeyframeMatch({ - matches: getBindingKeyframeMatches({ - animations, - binding, - }).filter(({ keyframe }) => keyframe.id === keyframeId), + const keyframeMatch = getPreferredChannelKeyframeMatch({ + matches: getChannelKeyframeMatches({ data }).filter( + ({ keyframe }) => keyframe.id === keyframeId, + ), }); if (!keyframeMatch) { return null; } return toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }); diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts index 12dc777d..c3fd8202 100644 --- a/apps/web/src/animation/keyframes.ts +++ b/apps/web/src/animation/keyframes.ts @@ -1,11 +1,8 @@ import type { - AnimationBindingInstance, - AnimationBindingKind, AnimationChannel, + ChannelData, AnimationInterpolation, AnimationPath, - AnimationPropertyPath, - AnimationValue, DiscreteAnimationChannel, DiscreteAnimationKey, ElementAnimations, @@ -14,14 +11,18 @@ import type { ScalarCurveKeyframePatch, ScalarSegmentType, } from "@/animation/types"; -import { generateUUID } from "@/utils/id"; +import type { + ChannelComponentDefinition, + ParamChannelLayout, + ParamValue, +} from "@/params"; import { - cloneAnimationBinding, - createAnimationBinding, - decomposeAnimationValue, -} from "./binding-values"; + getChannelsFromData, + isCompositeChannelData, + isAnimationStorageKey, + isLeafChannelData, +} from "./channel-data"; import { - getBezierPoint, getDefaultLeftHandle, getDefaultRightHandle, solveBezierProgressForTime, @@ -29,12 +30,18 @@ import { import { getChannelValueAtTime, getScalarSegmentInterpolation, + isScalarChannel, normalizeChannel, + normalizeDiscreteChannel, + normalizeScalarChannel, } from "./interpolation"; import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, -} from "./property-registry"; + type MediaTime, + roundMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; +import { generateUUID } from "@/utils/id"; function isNearlySameTime({ leftTime, @@ -54,27 +61,27 @@ function hasChannelKeys({ return Boolean(channel && channel.keys.length > 0); } +function hasChannelData({ data }: { data: ChannelData | undefined }): boolean { + return getChannelsFromData({ data }).some((channel) => + hasChannelKeys({ channel }), + ); +} + function toAnimation({ animations, }: { animations: ElementAnimations; }): ElementAnimations | undefined { - const nextBindings = Object.fromEntries( - Object.entries(animations.bindings).filter(([, binding]) => binding), - ); - const nextChannels = Object.fromEntries( - Object.entries(animations.channels).filter(([, channel]) => - hasChannelKeys({ channel }), + const nextAnimations = Object.fromEntries( + Object.entries(animations).filter( + ([key, data]) => isAnimationStorageKey({ key }) && hasChannelData({ data }), ), ); - if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) { + if (Object.keys(nextAnimations).length === 0) { return undefined; } - return { - bindings: nextBindings, - channels: nextChannels, - }; + return nextAnimations; } function cloneAnimationsState({ @@ -82,34 +89,88 @@ function cloneAnimationsState({ }: { animations: ElementAnimations | undefined; }): ElementAnimations { - return { - bindings: { ...(animations?.bindings ?? {}) }, - channels: { ...(animations?.channels ?? {}) }, - }; + return { ...(animations ?? {}) }; } -function getBindingChannelKind({ - kind, +function getChannelFromData({ + data, + componentKey, }: { - kind: AnimationBindingKind; -}): AnimationChannel["kind"] { - return kind === "discrete" ? "discrete" : "scalar"; + data: ChannelData | undefined; + componentKey: string; +}): AnimationChannel | undefined { + if (isLeafChannelData(data)) { + return componentKey === "value" ? data : undefined; + } + if (isCompositeChannelData(data)) { + return data[componentKey]; + } + return undefined; } -function getPrimaryComponent({ - binding, +type LayoutComponent = ChannelComponentDefinition; + +function getLayoutComponents({ + channelLayout, }: { - binding: AnimationBindingInstance; -}) { - return binding.components[0] ?? null; + channelLayout: ParamChannelLayout; +}): LayoutComponent[] { + return channelLayout.kind === "leaf" + ? [channelLayout.component] + : channelLayout.components; } -function getPrimaryChannelId({ - binding, +function getPrimaryComponentKey({ + channelLayout, }: { - binding: AnimationBindingInstance; -}) { - return getPrimaryComponent({ binding })?.channelId ?? null; + channelLayout: ParamChannelLayout; +}): string { + return getLayoutComponents({ channelLayout })[0]?.key ?? "value"; +} + +function getPrimaryChannelFromData({ + data, + channelLayout, +}: { + data: ChannelData | undefined; + channelLayout: ParamChannelLayout; +}): AnimationChannel | undefined { + return getChannelFromData({ + data, + componentKey: getPrimaryComponentKey({ channelLayout }), + }); +} + +function setChannelInData({ + data, + componentKey, + channel, +}: { + data: ChannelData | undefined; + componentKey: string; + channel: AnimationChannel | undefined; +}): ChannelData | undefined { + if (componentKey === "value") { + return channel; + } + const components = isCompositeChannelData(data) ? { ...data } : {}; + if (channel && hasChannelKeys({ channel })) { + components[componentKey] = channel; + } else { + delete components[componentKey]; + } + return Object.keys(components).length > 0 ? components : undefined; +} + +function getChannelDataEntries({ + data, +}: { + data: ChannelData | undefined; +}): Array<[string, AnimationChannel | undefined]> { + if (isLeafChannelData(data)) { + return [["value", data]]; + } + return isCompositeChannelData(data) ? Object.entries(data) : []; } function getScalarSegmentType({ @@ -123,14 +184,14 @@ function getScalarSegmentType({ return interpolation === "bezier" ? "bezier" : "linear"; } -function getInterpolationForBinding({ - kind, +function getInterpolationForComponent({ + component, interpolation, }: { - kind: AnimationBindingKind; + component: LayoutComponent; interpolation: AnimationInterpolation | undefined; }): AnimationInterpolation { - if (kind === "discrete") { + if (component.valueKind === "discrete") { return "hold"; } @@ -142,25 +203,25 @@ function getInterpolationForBinding({ return interpolation; } - return "linear"; + return component.defaultInterpolation; } -function createEmptyChannelForBindingKind({ - kind, +function decomposeChannelLayoutValue({ + channelLayout, + value, }: { - kind: AnimationBindingKind; -}): AnimationChannel { - if (kind === "discrete") { - return { - kind: "discrete", - keys: [], - } satisfies DiscreteAnimationChannel; + channelLayout: ParamChannelLayout; + value: ParamValue; +}): Record | null { + if (channelLayout.kind === "leaf") { + return { [channelLayout.component.key]: value }; + } + if (typeof value !== "string") { + return null; } - return { - kind: "scalar", - keys: [], - } satisfies ScalarAnimationChannel; + const components = channelLayout.decompose(value); + return components ? { ...components } : null; } function createScalarKey({ @@ -171,7 +232,7 @@ function createScalarKey({ previousKey, }: { id: string; - time: number; + time: MediaTime; value: number; interpolation?: AnimationInterpolation; previousKey?: ScalarAnimationKey; @@ -195,7 +256,7 @@ function createDiscreteKey({ value, }: { id: string; - time: number; + time: MediaTime; value: string | boolean; }): DiscreteAnimationKey { return { @@ -205,34 +266,20 @@ function createDiscreteKey({ }; } -function getBinding({ +function isDiscreteChannel( + channel: AnimationChannel | undefined, +): channel is DiscreteAnimationChannel { + return channel != null && !isScalarChannel(channel); +} + +function getChannelData({ animations, propertyPath, }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; -}): AnimationBindingInstance | undefined { - return animations?.bindings[propertyPath]; -} - -function getChannelById({ - animations, - channelId, -}: { - animations: ElementAnimations | undefined; - channelId: string; -}): AnimationChannel | undefined { - return animations?.channels[channelId]; -} - -function getBindingComponent({ - binding, - componentKey, -}: { - binding: AnimationBindingInstance; - componentKey: string; -}) { - return binding.components.find((component) => component.key === componentKey) ?? null; +}): ChannelData | undefined { + return animations?.[propertyPath]; } function getTargetKeyMetadata({ @@ -241,7 +288,7 @@ function getTargetKeyMetadata({ keyframeId, }: { channel: AnimationChannel | undefined; - time: number; + time: MediaTime; keyframeId?: string; }) { const normalizedChannel = @@ -280,12 +327,12 @@ function upsertDiscreteChannelKey({ keyframeId, }: { channel: DiscreteAnimationChannel | undefined; - time: number; + time: MediaTime; value: string | boolean; keyframeId?: string; }): DiscreteAnimationChannel { - const normalizedChannel = normalizeChannel({ - channel: channel ?? { kind: "discrete", keys: [] }, + const normalizedChannel = normalizeDiscreteChannel({ + channel: channel ?? { keys: [] }, }); const keys = [...normalizedChannel.keys]; if (keyframeId) { @@ -296,8 +343,8 @@ function upsertDiscreteChannelKey({ time, value, }); - return normalizeChannel({ - channel: { kind: "discrete", keys }, + return normalizeDiscreteChannel({ + channel: { keys }, }); } } @@ -311,8 +358,8 @@ function upsertDiscreteChannelKey({ time: keys[existingAtTimeIndex].time, value, }); - return normalizeChannel({ - channel: { kind: "discrete", keys }, + return normalizeDiscreteChannel({ + channel: { keys }, }); } @@ -323,8 +370,8 @@ function upsertDiscreteChannelKey({ value, }), ); - return normalizeChannel({ - channel: { kind: "discrete", keys }, + return normalizeDiscreteChannel({ + channel: { keys }, }); } @@ -337,14 +384,14 @@ function upsertScalarChannelKey({ keyframeId, }: { channel: ScalarAnimationChannel | undefined; - time: number; + time: MediaTime; value: number; interpolation?: AnimationInterpolation; defaultInterpolation?: AnimationInterpolation; keyframeId?: string; }): ScalarAnimationChannel { - const normalizedChannel = normalizeChannel({ - channel: channel ?? { kind: "scalar", keys: [] }, + const normalizedChannel = normalizeScalarChannel({ + channel: channel ?? { keys: [] }, }); const keys = [...normalizedChannel.keys]; if (keyframeId) { @@ -363,9 +410,8 @@ function upsertScalarChannelKey({ } : keys[existingIndex], }); - return normalizeChannel({ + return normalizeScalarChannel({ channel: { - kind: "scalar", keys, extrapolation: normalizedChannel.extrapolation, }, @@ -390,9 +436,8 @@ function upsertScalarChannelKey({ } : keys[existingAtTimeIndex], }); - return normalizeChannel({ + return normalizeScalarChannel({ channel: { - kind: "scalar", keys, extrapolation: normalizedChannel.extrapolation, }, @@ -407,9 +452,8 @@ function upsertScalarChannelKey({ interpolation: interpolation ?? defaultInterpolation, }), ); - return normalizeChannel({ + return normalizeScalarChannel({ channel: { - kind: "scalar", keys, extrapolation: normalizedChannel.extrapolation, }, @@ -423,10 +467,11 @@ export function getChannel({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; }): AnimationChannel | undefined { - const binding = getBinding({ animations, propertyPath }); - const primaryChannelId = - binding != null ? getPrimaryChannelId({ binding }) : null; - return primaryChannelId ? animations?.channels[primaryChannelId] : undefined; + const data = getChannelData({ animations, propertyPath }); + if (isLeafChannelData(data)) { + return data; + } + return getChannelsFromData({ data })[0]; } export function upsertPathKeyframe({ @@ -436,19 +481,17 @@ export function upsertPathKeyframe({ value, interpolation, keyframeId, - kind, - defaultInterpolation, + channelLayout, coerceValue, }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + channelLayout: ParamChannelLayout; + coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null; }): ElementAnimations | undefined { const coercedValue = coerceValue({ value }); if (coercedValue === null) { @@ -456,118 +499,84 @@ export function upsertPathKeyframe({ } const nextAnimations = cloneAnimationsState({ animations }); - const existingBinding = getBinding({ - animations, - propertyPath, - }); - const binding = - existingBinding && existingBinding.kind === kind - ? cloneAnimationBinding({ binding: existingBinding }) - : createAnimationBinding({ path: propertyPath, kind }); - const primaryChannel = getChannel({ - animations, - propertyPath, + const currentData = getChannelData({ animations, propertyPath }); + const primaryChannel = getPrimaryChannelFromData({ + data: currentData, + channelLayout, }); const targetKey = getTargetKeyMetadata({ channel: primaryChannel, time, keyframeId, }); - const componentValues = decomposeAnimationValue({ - kind, + const componentValues = decomposeChannelLayoutValue({ + channelLayout, value: coercedValue, }); if (!componentValues) { return animations; } - const explicitInterpolation = - interpolation != null - ? getInterpolationForBinding({ kind, interpolation }) - : undefined; - const validatedDefaultInterpolation = getInterpolationForBinding({ - kind, - interpolation: defaultInterpolation, - }); - nextAnimations.bindings[propertyPath] = binding; - for (const component of binding.components) { - const nextValue = componentValues[component.key]; + let nextData: ChannelData | undefined = currentData; + for (const component of getLayoutComponents({ channelLayout })) { + const componentKey = component.key; + const nextValue = componentValues[componentKey]; if (nextValue == null) { continue; } - const currentChannel = getChannelById({ - animations, - channelId: component.channelId, + const currentChannel = getChannelFromData({ + data: currentData, + componentKey, + }); + if (component.valueKind === "discrete") { + if (typeof nextValue !== "string" && typeof nextValue !== "boolean") { + continue; + } + const nextChannel = upsertDiscreteChannelKey({ + channel: isDiscreteChannel(currentChannel) ? currentChannel : undefined, + time: targetKey.time, + value: nextValue, + keyframeId: targetKey.id, + }); + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: nextChannel, + }); + continue; + } + + if (typeof nextValue !== "number") { + continue; + } + const nextChannel = upsertScalarChannelKey({ + channel: + currentChannel != null && isScalarChannel(currentChannel) + ? currentChannel + : undefined, + time: targetKey.time, + value: nextValue, + interpolation: + interpolation != null + ? getInterpolationForComponent({ component, interpolation }) + : undefined, + defaultInterpolation: component.defaultInterpolation, + keyframeId: targetKey.id, + }); + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: nextChannel, }); - const targetChannel = - currentChannel?.kind === getBindingChannelKind({ kind }) - ? currentChannel - : createEmptyChannelForBindingKind({ kind }); - nextAnimations.channels[component.channelId] = - targetChannel.kind === "discrete" - ? upsertDiscreteChannelKey({ - channel: targetChannel, - time: targetKey.time, - value: nextValue as string | boolean, - keyframeId: targetKey.id, - }) - : upsertScalarChannelKey({ - channel: targetChannel, - time: targetKey.time, - value: nextValue as number, - interpolation: explicitInterpolation, - defaultInterpolation: validatedDefaultInterpolation, - keyframeId: targetKey.id, - }); } + nextAnimations[propertyPath] = nextData; return toAnimation({ animations: nextAnimations, }); } -export function upsertElementKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath, - value, - }); - if (coercedValue === null) { - return animations; - } - - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return upsertPathKeyframe({ - animations, - propertyPath, - time, - value: coercedValue, - interpolation, - keyframeId, - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - coerceValue: ({ value: nextValue }) => - coerceAnimationValueForProperty({ - propertyPath, - value: nextValue, - }), - }); -} - export function upsertKeyframe({ channel, time, @@ -576,8 +585,8 @@ export function upsertKeyframe({ keyframeId, }: { channel: AnimationChannel | undefined; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; }): AnimationChannel | undefined { @@ -585,13 +594,9 @@ export function upsertKeyframe({ return undefined; } - if (channel.kind === "discrete") { - if (typeof value !== "string" && typeof value !== "boolean") { - return channel; - } - + if (typeof value === "string" || typeof value === "boolean") { return upsertDiscreteChannelKey({ - channel, + channel: isDiscreteChannel(channel) ? channel : undefined, time, value, keyframeId, @@ -603,7 +608,7 @@ export function upsertKeyframe({ } return upsertScalarChannelKey({ - channel, + channel: isScalarChannel(channel) ? channel : undefined, time, value, interpolation, @@ -622,16 +627,30 @@ export function removeKeyframe({ return undefined; } + if (isScalarChannel(channel)) { + const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); + if (nextKeys.length === 0) { + return undefined; + } + + return normalizeScalarChannel({ + channel: { + ...channel, + keys: nextKeys, + }, + }); + } + const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); if (nextKeys.length === 0) { return undefined; } - return normalizeChannel({ + return normalizeDiscreteChannel({ channel: { ...channel, keys: nextKeys, - } as AnimationChannel, + }, }); } @@ -642,12 +661,34 @@ export function retimeKeyframe({ }: { channel: AnimationChannel | undefined; keyframeId: string; - time: number; + time: MediaTime; }): AnimationChannel | undefined { if (!channel) { return undefined; } + if (isScalarChannel(channel)) { + const keyframeByIdIndex = channel.keys.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeByIdIndex < 0) { + return channel; + } + + const nextKeys = [...channel.keys]; + nextKeys[keyframeByIdIndex] = { + ...nextKeys[keyframeByIdIndex], + time, + }; + + return normalizeScalarChannel({ + channel: { + ...channel, + keys: nextKeys, + }, + }); + } + const keyframeByIdIndex = channel.keys.findIndex( (keyframe) => keyframe.id === keyframeId, ); @@ -661,11 +702,11 @@ export function retimeKeyframe({ time, }; - return normalizeChannel({ + return normalizeDiscreteChannel({ channel: { ...channel, keys: nextKeys, - } as AnimationChannel, + }, }); } @@ -678,26 +719,10 @@ export function setChannel({ propertyPath: AnimationPath; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - if (binding.components.length !== 1) { - throw new Error( - `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, - ); - } - - const primaryComponent = getPrimaryComponent({ binding }); - if (!primaryComponent) { - return animations; - } - return setBindingComponentChannel({ animations, propertyPath, - componentKey: primaryComponent.key, + componentKey: "value", channel, }); } @@ -713,37 +738,14 @@ export function setBindingComponentChannel({ componentKey: string; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const component = getBindingComponent({ - binding, - componentKey, - }); - if (!component) { - return animations; - } - const nextAnimations = cloneAnimationsState({ animations }); - if (!channel || !hasChannelKeys({ channel })) { - delete nextAnimations.channels[component.channelId]; - const hasRemainingKeys = binding.components.some((candidate) => - hasChannelKeys({ - channel: nextAnimations.channels[candidate.channelId], - }), - ); - if (!hasRemainingKeys) { - delete nextAnimations.bindings[propertyPath]; - } - return toAnimation({ - animations: nextAnimations, - }); - } - - nextAnimations.channels[component.channelId] = normalizeChannel({ - channel, + nextAnimations[propertyPath] = setChannelInData({ + data: nextAnimations[propertyPath], + componentKey, + channel: + channel && hasChannelKeys({ channel }) + ? normalizeChannel({ channel }) + : undefined, }); return toAnimation({ animations: nextAnimations, @@ -763,24 +765,11 @@ export function updateScalarKeyframeCurve({ keyframeId: string; patch: ScalarCurveKeyframePatch; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const component = getBindingComponent({ - binding, + const channel = getChannelFromData({ + data: getChannelData({ animations, propertyPath }), componentKey, }); - if (!component) { - return animations; - } - - const channel = getChannelById({ - animations, - channelId: component.channelId, - }); - if (channel?.kind !== "scalar") { + if (!channel || !isScalarChannel(channel)) { return animations; } @@ -810,13 +799,40 @@ export function updateScalarKeyframeCurve({ propertyPath, componentKey, channel: { - kind: "scalar", keys: nextKeys, extrapolation: channel.extrapolation, }, }); } +function cloneChannelWithKeyIds({ + channel, + keyIdMap, +}: { + channel: AnimationChannel; + keyIdMap: Map; +}): AnimationChannel { + return isScalarChannel(channel) + ? normalizeScalarChannel({ + channel: { + ...channel, + keys: channel.keys.map((key) => ({ + ...key, + id: keyIdMap.get(key.id) ?? key.id, + })), + }, + }) + : normalizeDiscreteChannel({ + channel: { + ...channel, + keys: channel.keys.map((key) => ({ + ...key, + id: keyIdMap.get(key.id) ?? key.id, + })), + }, + }); +} + export function cloneAnimations({ animations, shouldRegenerateKeyframeIds = false, @@ -829,23 +845,11 @@ export function cloneAnimations({ } const nextAnimations = cloneAnimationsState({ animations }); - nextAnimations.bindings = Object.fromEntries( - Object.entries(animations.bindings).map(([path, binding]) => [ - path, - binding ? cloneAnimationBinding({ binding }) : binding, - ]), - ); - nextAnimations.channels = {}; - - for (const binding of Object.values(nextAnimations.bindings)) { - if (!binding) { - continue; - } - - const primaryChannel = getChannelById({ - animations, - channelId: getPrimaryChannelId({ binding }) ?? "", - }); + for (const [propertyPath, data] of Object.entries(animations).filter(([key]) => + isAnimationStorageKey({ key }), + )) { + const channels = getChannelsFromData({ data }); + const primaryChannel = channels[0]; const keyIdMap = new Map(); if (primaryChannel) { for (const key of primaryChannel.keys) { @@ -856,24 +860,22 @@ export function cloneAnimations({ } } - for (const component of binding.components) { - const currentChannel = getChannelById({ - animations, - channelId: component.channelId, - }); - if (!currentChannel) { - continue; - } - - nextAnimations.channels[component.channelId] = normalizeChannel({ - channel: { - ...currentChannel, - keys: currentChannel.keys.map((key) => ({ - ...key, - id: keyIdMap.get(key.id) ?? key.id, - })), - } as AnimationChannel, + if (isLeafChannelData(data)) { + nextAnimations[propertyPath] = cloneChannelWithKeyIds({ + channel: data, + keyIdMap, }); + continue; + } + if (isCompositeChannelData(data)) { + nextAnimations[propertyPath] = Object.fromEntries( + Object.entries(data).map(([componentKey, channel]) => [ + componentKey, + channel + ? cloneChannelWithKeyIds({ channel, keyIdMap }) + : undefined, + ]), + ); } } @@ -887,7 +889,7 @@ export function clampAnimationsToDuration({ duration, }: { animations: ElementAnimations | undefined; - duration: number; + duration: MediaTime; }): ElementAnimations | undefined { if (!animations || duration <= 0) { return undefined; @@ -923,7 +925,7 @@ function splitDiscreteChannelAtTime({ shouldIncludeSplitBoundary, }: { channel: DiscreteAnimationChannel | undefined; - splitTime: number; + splitTime: MediaTime; leftBoundaryId: string; rightBoundaryId: string; shouldIncludeSplitBoundary: boolean; @@ -939,7 +941,10 @@ function splitDiscreteChannelAtTime({ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let rightKeys = normalizedChannel.keys .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); + .map((key) => ({ + ...key, + time: subMediaTime({ a: key.time, b: splitTime }), + })); if (shouldIncludeSplitBoundary) { const hasBoundaryOnLeft = leftKeys.some((key) => @@ -959,7 +964,7 @@ function splitDiscreteChannelAtTime({ createDiscreteKey({ id: leftBoundaryId, time: splitTime, - value: boundaryValue as string | boolean, + value: boundaryValue, }), ]; } @@ -967,8 +972,8 @@ function splitDiscreteChannelAtTime({ rightKeys = [ createDiscreteKey({ id: rightBoundaryId, - time: 0, - value: boundaryValue as string | boolean, + time: ZERO_MEDIA_TIME, + value: boundaryValue, }), ...rightKeys, ]; @@ -977,10 +982,10 @@ function splitDiscreteChannelAtTime({ return { leftChannel: leftKeys.length - ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } }) + ? normalizeChannel({ channel: { keys: leftKeys } }) : undefined, rightChannel: rightKeys.length - ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } }) + ? normalizeChannel({ channel: { keys: rightKeys } }) : undefined, }; } @@ -993,7 +998,7 @@ function splitScalarChannelAtTime({ shouldIncludeSplitBoundary, }: { channel: ScalarAnimationChannel | undefined; - splitTime: number; + splitTime: MediaTime; leftBoundaryId: string; rightBoundaryId: string; shouldIncludeSplitBoundary: boolean; @@ -1009,7 +1014,10 @@ function splitScalarChannelAtTime({ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let rightKeys = normalizedChannel.keys .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); + .map((key) => ({ + ...key, + time: subMediaTime({ a: key.time, b: splitTime }), + })); const hasBoundaryOnLeft = leftKeys.some((key) => isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), @@ -1022,7 +1030,6 @@ function splitScalarChannelAtTime({ leftChannel: leftKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: leftKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1031,7 +1038,6 @@ function splitScalarChannelAtTime({ rightChannel: rightKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: rightKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1056,7 +1062,7 @@ function splitScalarChannelAtTime({ channel: normalizedChannel, time: splitTime, fallbackValue: leftKey.value, - }) as number; + }); if (leftKey.segmentToNext === "bezier") { const rightHandle = @@ -1089,7 +1095,7 @@ function splitScalarChannelAtTime({ { ...leftKey, rightHandle: { - dt: q0.x - p0.x, + dt: roundMediaTime({ time: q0.x - p0.x }), dv: q0.y - p0.y, }, }, @@ -1098,7 +1104,7 @@ function splitScalarChannelAtTime({ time: splitTime, value: boundaryValue, leftHandle: { - dt: r0.x - splitPoint.x, + dt: roundMediaTime({ time: r0.x - splitPoint.x }), dv: r0.y - splitPoint.y, }, segmentToNext: leftKey.segmentToNext, @@ -1108,10 +1114,10 @@ function splitScalarChannelAtTime({ rightKeys = [ { id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue, rightHandle: { - dt: r1.x - splitPoint.x, + dt: roundMediaTime({ time: r1.x - splitPoint.x }), dv: r1.y - splitPoint.y, }, segmentToNext: "bezier", @@ -1119,9 +1125,9 @@ function splitScalarChannelAtTime({ }, { ...rightKey, - time: rightKey.time - splitTime, + time: subMediaTime({ a: rightKey.time, b: splitTime }), leftHandle: { - dt: q2.x - p3.x, + dt: roundMediaTime({ time: q2.x - p3.x }), dv: q2.y - p3.y, }, }, @@ -1129,7 +1135,7 @@ function splitScalarChannelAtTime({ .filter((key) => key.time > rightKey.time) .map((key) => ({ ...key, - time: key.time - splitTime, + time: subMediaTime({ a: key.time, b: splitTime }), })), ]; } else { @@ -1145,7 +1151,7 @@ function splitScalarChannelAtTime({ rightKeys = [ createScalarKey({ id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue, interpolation: getScalarSegmentInterpolation({ segment: leftKey.segmentToNext, @@ -1158,14 +1164,12 @@ function splitScalarChannelAtTime({ return { leftChannel: normalizeChannel({ channel: { - kind: "scalar", keys: leftKeys, extrapolation: normalizedChannel.extrapolation, }, }), rightChannel: normalizeChannel({ channel: { - kind: "scalar", keys: rightKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1177,7 +1181,6 @@ function splitScalarChannelAtTime({ leftChannel: leftKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: leftKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1186,7 +1189,6 @@ function splitScalarChannelAtTime({ rightChannel: rightKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: rightKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1195,13 +1197,44 @@ function splitScalarChannelAtTime({ }; } +function splitChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: AnimationChannel | undefined; + splitTime: MediaTime; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + return channel != null && !isScalarChannel(channel) + ? splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }) + : splitScalarChannelAtTime({ + channel: + channel != null && isScalarChannel(channel) ? channel : undefined, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }); +} + export function splitAnimationsAtTime({ animations, splitTime, shouldIncludeSplitBoundary = true, }: { animations: ElementAnimations | undefined; - splitTime: number; + splitTime: MediaTime; shouldIncludeSplitBoundary?: boolean; }): { leftAnimations: ElementAnimations | undefined; @@ -1214,55 +1247,39 @@ export function splitAnimationsAtTime({ const leftAnimations = cloneAnimationsState({ animations: undefined }); const rightAnimations = cloneAnimationsState({ animations: undefined }); - for (const [propertyPath, binding] of Object.entries(animations.bindings)) { - if (!binding) { + for (const [propertyPath, data] of Object.entries(animations).filter(([key]) => + isAnimationStorageKey({ key }), + )) { + if (!data) { continue; } - const leftBinding = cloneAnimationBinding({ binding }); - const rightBinding = cloneAnimationBinding({ binding }); const leftBoundaryId = generateUUID(); const rightBoundaryId = generateUUID(); - let hasLeftKeys = false; - let hasRightKeys = false; - for (const component of binding.components) { - const channel = getChannelById({ - animations, - channelId: component.channelId, + for (const [componentKey, channel] of getChannelDataEntries({ data })) { + const splitResult = splitChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, }); - const splitResult = - channel?.kind === "discrete" - ? splitDiscreteChannelAtTime({ - channel, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, - }) - : splitScalarChannelAtTime({ - channel: channel as ScalarAnimationChannel | undefined, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, - }); if (splitResult.leftChannel) { - leftAnimations.channels[component.channelId] = splitResult.leftChannel; - hasLeftKeys = true; + leftAnimations[propertyPath] = setChannelInData({ + data: leftAnimations[propertyPath], + componentKey, + channel: splitResult.leftChannel, + }); } if (splitResult.rightChannel) { - rightAnimations.channels[component.channelId] = splitResult.rightChannel; - hasRightKeys = true; + rightAnimations[propertyPath] = setChannelInData({ + data: rightAnimations[propertyPath], + componentKey, + channel: splitResult.rightChannel, + }); } } - - if (hasLeftKeys) { - leftAnimations.bindings[propertyPath] = leftBinding; - } - if (hasRightKeys) { - rightAnimations.bindings[propertyPath] = rightBinding; - } } return { @@ -1280,28 +1297,24 @@ export function removeElementKeyframe({ propertyPath: AnimationPath; keyframeId: string; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { + const data = getChannelData({ animations, propertyPath }); + if (!data) { return animations; } const nextAnimations = cloneAnimationsState({ animations }); - for (const component of binding.components) { - nextAnimations.channels[component.channelId] = removeKeyframe({ - channel: nextAnimations.channels[component.channelId], - keyframeId, - }); - } - const hasRemainingKeys = binding.components.some((component) => - hasChannelKeys({ - channel: nextAnimations.channels[component.channelId], - }), - ); - if (!hasRemainingKeys) { - delete nextAnimations.bindings[propertyPath]; - for (const component of binding.components) { - delete nextAnimations.channels[component.channelId]; + if (isLeafChannelData(data)) { + nextAnimations[propertyPath] = removeKeyframe({ channel: data, keyframeId }); + } else if (isCompositeChannelData(data)) { + let nextData: ChannelData | undefined = data; + for (const [componentKey, channel] of Object.entries(data)) { + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: removeKeyframe({ channel, keyframeId }), + }); } + nextAnimations[propertyPath] = nextData; } return toAnimation({ animations: nextAnimations, @@ -1317,20 +1330,30 @@ export function retimeElementKeyframe({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; keyframeId: string; - time: number; + time: MediaTime; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { + const data = getChannelData({ animations, propertyPath }); + if (!data) { return animations; } const nextAnimations = cloneAnimationsState({ animations }); - for (const component of binding.components) { - nextAnimations.channels[component.channelId] = retimeKeyframe({ - channel: nextAnimations.channels[component.channelId], + if (isLeafChannelData(data)) { + nextAnimations[propertyPath] = retimeKeyframe({ + channel: data, keyframeId, time, }); + } else if (isCompositeChannelData(data)) { + let nextData: ChannelData | undefined = data; + for (const [componentKey, channel] of Object.entries(data)) { + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: retimeKeyframe({ channel, keyframeId, time }), + }); + } + nextAnimations[propertyPath] = nextData; } return toAnimation({ animations: nextAnimations, diff --git a/apps/web/src/animation/path.ts b/apps/web/src/animation/path.ts new file mode 100644 index 00000000..69e5e228 --- /dev/null +++ b/apps/web/src/animation/path.ts @@ -0,0 +1,22 @@ +import type { AnimationPath, AnimationPropertyPath } from "@/animation/types"; +import { ANIMATION_PROPERTY_PATHS } from "./types"; +import { isEffectParamPath } from "./effect-param-channel"; +import { isGraphicParamPath } from "./graphic-param-channel"; + +const ANIMATION_PROPERTY_PATH_SET = new Set(ANIMATION_PROPERTY_PATHS); + +export function isAnimationPropertyPath( + propertyPath: string, +): propertyPath is AnimationPropertyPath { + return ANIMATION_PROPERTY_PATH_SET.has(propertyPath); +} + +export function isAnimationPath( + propertyPath: string, +): propertyPath is AnimationPath { + return ( + isAnimationPropertyPath(propertyPath) || + isGraphicParamPath(propertyPath) || + isEffectParamPath(propertyPath) + ); +} diff --git a/apps/web/src/animation/property-registry.ts b/apps/web/src/animation/property-registry.ts deleted file mode 100644 index aacb9972..00000000 --- a/apps/web/src/animation/property-registry.ts +++ /dev/null @@ -1,390 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, -} from "@/animation/types"; -import { parseColorToLinearRgba } from "./binding-values"; -import type { TimelineElement } from "@/timeline"; -import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/text/background"; -import { - canElementHaveAudio, - isVisualElement, -} from "@/timeline/element-utils"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { DEFAULTS } from "@/timeline/defaults"; -import { snapToStep } from "@/utils/math"; - -export interface NumericSpec { - min?: number; - max?: number; - step?: number; -} - -export interface AnimationPropertyDefinition { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial>; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceNumberValue({ - value, - numericRange, -}: { - value: AnimationValue; - numericRange?: NumericSpec; -}): number | null { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ value, numericRange }); -} - -function coerceColorValue({ - value, -}: { - value: AnimationValue; -}): string | null { - return typeof value === "string" && parseColorToLinearRgba({ color: value }) - ? value - : null; -} - -function createNumberPropertyDefinition({ - numericRange, - supportsElement, - getValue, - setValue, -}: { - numericRange?: NumericSpec; - supportsElement: AnimationPropertyDefinition["supportsElement"]; - getValue: AnimationPropertyDefinition["getValue"]; - setValue: AnimationPropertyDefinition["setValue"]; -}): AnimationPropertyDefinition { - return { - kind: "number", - defaultInterpolation: "linear", - numericRanges: numericRange ? { value: numericRange } : undefined, - supportsElement, - getValue, - coerceValue: ({ value }) => - coerceNumberValue({ - value, - numericRange, - }), - setValue, - }; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.positionX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.x : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, x: value as number }, - }, - } - : element, - }), - "transform.positionY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.y : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, y: value as number }, - }, - } - : element, - }), - "transform.scaleX": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }), - "transform.scaleY": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }), - "transform.rotate": createNumberPropertyDefinition({ - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }), - opacity: createNumberPropertyDefinition({ - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }), - volume: createNumberPropertyDefinition({ - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }), - color: { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }), - "background.paddingY": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }), - "background.offsetX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }), - "background.offsetY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }), - "background.cornerRadius": createNumberPropertyDefinition({ - numericRange: { - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - step: 1, - }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }), -}; - -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const definition = getAnimationPropertyDefinition({ propertyPath }); - const coercedValue = definition.coerceValue({ value }); - if (coercedValue === null || !definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.coerceValue({ value }); -} diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts index 1b51c126..331dcbaa 100644 --- a/apps/web/src/animation/resolve.ts +++ b/apps/web/src/animation/resolve.ts @@ -1,19 +1,13 @@ import type { - AnimationColorPropertyPath, - AnimationNumericPropertyPath, AnimationPath, - AnimationPropertyPath, - AnimationValueForPath, ElementAnimations, } from "@/animation/types"; -import type { Transform } from "@/rendering"; -import { - type AnimationComponentValue, - composeAnimationValue, - decomposeAnimationValue, -} from "./binding-values"; +import { formatLinearRgba, parseColorToLinearRgba } from "@/params"; +import type { ParamValue } from "@/params"; +import { isCompositeChannelData, isLeafChannelData } from "./channel-data"; import { getChannelValueAtTime, + isScalarChannel, } from "./interpolation"; export function getElementLocalTime({ @@ -37,148 +31,123 @@ export function getElementLocalTime({ return localTime; } -export function resolveTransformAtTime({ - baseTransform, - animations, - localTime, -}: { - baseTransform: Transform; - animations: ElementAnimations | undefined; - localTime: number; -}): Transform { - const safeLocalTime = Math.max(0, localTime); - return { - position: { - x: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionX", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.x, - }), - y: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.positionY", - localTime: safeLocalTime, - fallbackValue: baseTransform.position.y, - }), - }, - scaleX: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleX", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleX, - }), - scaleY: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.scaleY", - localTime: safeLocalTime, - fallbackValue: baseTransform.scaleY, - }), - rotate: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.rotate", - localTime: safeLocalTime, - fallbackValue: baseTransform.rotate, - }), - }; -} - -export function resolveOpacityAtTime({ - baseOpacity, - animations, - localTime, -}: { - baseOpacity: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath: "opacity", - localTime: Math.max(0, localTime), - fallbackValue: baseOpacity, - }); -} - -export function resolveNumberAtTime({ - baseValue, - animations, - propertyPath, - localTime, -}: { - baseValue: number; - animations: ElementAnimations | undefined; - propertyPath: AnimationNumericPropertyPath; - localTime: number; -}): number { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseValue, - }); -} - -export function resolveColorAtTime({ - baseColor, - animations, - propertyPath, - localTime, -}: { - baseColor: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationColorPropertyPath; - localTime: number; -}): string { - return resolveAnimationPathValueAtTime({ - animations, - propertyPath, - localTime: Math.max(0, localTime), - fallbackValue: baseColor, - }); -} - -export function resolveAnimationPathValueAtTime({ +export function resolveAnimationPathValueAtTime({ animations, propertyPath, localTime, fallbackValue, }: { animations: ElementAnimations | undefined; - propertyPath: TPath; + propertyPath: AnimationPath; localTime: number; - fallbackValue: AnimationValueForPath; -}): AnimationValueForPath { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + fallbackValue: number; +}): number; +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: string; +}): string; +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: boolean; +}): boolean; +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: ParamValue; +}): ParamValue; +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: ParamValue; +}): ParamValue { + const data = animations?.[propertyPath]; + if (!data) { + return fallbackValue; + } + if (isLeafChannelData(data)) { + if (typeof fallbackValue === "number") { + return getChannelValueAtTime({ + channel: isScalarChannel(data) ? data : undefined, + time: localTime, + fallbackValue, + }); + } + if (typeof fallbackValue === "string" || typeof fallbackValue === "boolean") { + return getChannelValueAtTime({ + channel: !isScalarChannel(data) ? data : undefined, + time: localTime, + fallbackValue, + }); + } + return fallbackValue; + } + if (!isCompositeChannelData(data)) { return fallbackValue; } - const fallbackComponents = decomposeAnimationValue({ - kind: binding.kind, - value: fallbackValue, + if ( + typeof fallbackValue !== "string" || + !("r" in data) || + !("g" in data) || + !("b" in data) || + !("a" in data) + ) { + return fallbackValue; + } + + const fallbackComponents = parseColorToLinearRgba({ color: fallbackValue }); + if (fallbackComponents === null) { + return fallbackValue; + } + + return formatLinearRgba({ + color: { + r: getChannelValueAtTime({ + channel: data.r && isScalarChannel(data.r) ? data.r : undefined, + time: localTime, + fallbackValue: fallbackComponents.r, + }), + g: getChannelValueAtTime({ + channel: data.g && isScalarChannel(data.g) ? data.g : undefined, + time: localTime, + fallbackValue: fallbackComponents.g, + }), + b: getChannelValueAtTime({ + channel: data.b && isScalarChannel(data.b) ? data.b : undefined, + time: localTime, + fallbackValue: fallbackComponents.b, + }), + a: getChannelValueAtTime({ + channel: data.a && isScalarChannel(data.a) ? data.a : undefined, + time: localTime, + fallbackValue: fallbackComponents.a, + }), + }, }); - if (!fallbackComponents) { - return fallbackValue; - } - - const componentValues = Object.fromEntries( - binding.components.map((component) => { - const channel = animations?.channels[component.channelId]; - return [ - component.key, - getChannelValueAtTime({ - channel, - time: localTime, - fallbackValue: - fallbackComponents[component.key] ?? - (channel?.kind === "discrete" ? false : 0), - }), - ]; - }), - ) as Record; - return (composeAnimationValue({ - binding, - componentValues, - }) ?? fallbackValue) as AnimationValueForPath; } diff --git a/apps/web/src/animation/target-resolver.ts b/apps/web/src/animation/target-resolver.ts deleted file mode 100644 index 32e7917c..00000000 --- a/apps/web/src/animation/target-resolver.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPath, - AnimationValue, -} from "@/animation/types"; -import { - parseEffectParamPath, - isEffectParamPath, -} from "@/animation/effect-param-channel"; -import { - isGraphicParamPath, - parseGraphicParamPath, -} from "@/animation/graphic-param-channel"; -import type { ParamDefinition } from "@/params"; -import { effectsRegistry, registerDefaultEffects } from "@/effects"; -import { getGraphicDefinition } from "@/graphics"; -import type { TimelineElement } from "@/timeline"; -import { isVisualElement } from "@/timeline/element-utils"; -import { snapToStep } from "@/utils/math"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getElementBaseValueForProperty, - isAnimationPropertyPath, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; -import { parseColorToLinearRgba } from "./binding-values"; - -export interface AnimationPathDescriptor { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial>; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - getBaseValue: () => AnimationValue | null; - setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; -} - -export function getParamValueKind({ - param, -}: { - param: ParamDefinition; -}): AnimationBindingKind { - if (param.type === "number") { - return "number"; - } - - if (param.type === "color") { - return "color"; - } - - return "discrete"; -} - -export function getParamDefaultInterpolation({ - param, -}: { - param: ParamDefinition; -}): AnimationInterpolation { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; -} - -function getParamNumericRange({ - param, -}: { - param: ParamDefinition; -}): Partial> | undefined { - if (param.type !== "number") { - return undefined; - } - - return { - value: { - min: param.min, - max: param.max, - step: param.step, - }, - }; -} - -export function coerceAnimationValueForParam({ - param, - value, -}: { - param: ParamDefinition; - value: AnimationValue; -}): number | string | boolean | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const minValue = param.min; - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); - } - - if (param.type === "color") { - return typeof value === "string" ? value : null; - } - - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (typeof value !== "string") { - return null; - } - - return param.options.some((option) => option.value === value) ? value : null; -} - -function buildGraphicParamDescriptor({ - element, - paramKey, -}: { - element: TimelineElement; - paramKey: string; -}): AnimationPathDescriptor | null { - if (element.type !== "graphic") { - return null; - } - - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), - getBaseValue: () => element.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - params: { - ...element.params, - [param.key]: coercedValue, - }, - }; - }, - }; -} - -function buildEffectParamDescriptor({ - element, - effectId, - paramKey, -}: { - element: TimelineElement; - effectId: string; - paramKey: string; -}): AnimationPathDescriptor | null { - if (!isVisualElement(element)) { - return null; - } - - const effect = element.effects?.find((candidate) => candidate.id === effectId); - if (!effect) { - return null; - } - - registerDefaultEffects(); - const definition = effectsRegistry.get(effect.type); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - kind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRanges: getParamNumericRange({ param }), - coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), - getBaseValue: () => effect.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationValueForParam({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - effects: - element.effects?.map((candidate) => - candidate.id !== effectId - ? candidate - : { - ...candidate, - params: { - ...candidate.params, - [param.key]: coercedValue, - }, - }, - ) ?? element.effects, - }; - }, - }; -} - -export function isAnimationPath( - propertyPath: string, -): propertyPath is AnimationPath { - return ( - isAnimationPropertyPath(propertyPath) || - isGraphicParamPath(propertyPath) || - isEffectParamPath(propertyPath) - ); -} - -export function resolveAnimationTarget({ - element, - path, -}: { - element: TimelineElement; - path: AnimationPath; -}): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(path)) { - const propertyDefinition = getAnimationPropertyDefinition({ - propertyPath: path, - }); - if (!propertyDefinition.supportsElement({ element })) { - return null; - } - - return { - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - numericRanges: propertyDefinition.numericRanges, - coerceValue: ({ value }) => - coerceAnimationValueForProperty({ - propertyPath: path, - value, - }), - getBaseValue: () => - getElementBaseValueForProperty({ - element, - propertyPath: path, - }), - setBaseValue: ({ value }) => { - const coercedValue = propertyDefinition.coerceValue({ value }); - if (coercedValue === null) { - return element; - } - - return withElementBaseValueForProperty({ - element, - propertyPath: path, - value: coercedValue, - }); - }, - }; - } - - const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); - if (graphicParamTarget) { - return buildGraphicParamDescriptor({ - element, - paramKey: graphicParamTarget.paramKey, - }); - } - - const effectParamTarget = parseEffectParamPath({ propertyPath: path }); - if (effectParamTarget) { - return buildEffectParamDescriptor({ - element, - effectId: effectParamTarget.effectId, - paramKey: effectParamTarget.paramKey, - }); - } - - return null; -} diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts index 3afddfa1..9b5bd0c7 100644 --- a/apps/web/src/animation/types.ts +++ b/apps/web/src/animation/types.ts @@ -1,4 +1,5 @@ -import type { ParamValues } from "@/params"; +import type { MediaTime } from "@/wasm"; +import type { ParamValue } from "@/params"; export const ANIMATION_PROPERTY_PATHS = [ "transform.positionX", @@ -20,10 +21,7 @@ export const ANIMATION_PROPERTY_PATHS = [ export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; export type GraphicParamPath = `params.${string}`; export type EffectParamPath = `effects.${string}.params.${string}`; -export type AnimationPath = - | AnimationPropertyPath - | GraphicParamPath - | EffectParamPath; +export type AnimationPath = string; export const ANIMATION_PROPERTY_GROUPS = { "transform.scale": ["transform.scaleX", "transform.scaleY"], @@ -31,38 +29,21 @@ export const ANIMATION_PROPERTY_GROUPS = { export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; -export type VectorValue = { x: number; y: number }; export type DiscreteValue = boolean | string; -export type AnimationValue = number | string | boolean | VectorValue; -export interface AnimationPropertyValueMap { - "transform.positionX": number; - "transform.positionY": number; - "transform.scaleX": number; - "transform.scaleY": number; - "transform.rotate": number; - opacity: number; - volume: number; - color: string; - "background.color": string; - "background.paddingX": number; - "background.paddingY": number; - "background.offsetX": number; - "background.offsetY": number; - "background.cornerRadius": number; + +export interface NumericSpec { + min?: number; + max?: number; + step?: number; } -export type DynamicAnimationPathValue = ParamValues[string]; -export type AnimationValueForPath = - TPath extends AnimationPropertyPath - ? AnimationPropertyValueMap[TPath] - : TPath extends GraphicParamPath | EffectParamPath - ? DynamicAnimationPathValue - : never; -export type AnimationNumericPropertyPath = { - [K in AnimationPropertyPath]: AnimationValueForPath extends number ? K : never; -}[AnimationPropertyPath]; -export type AnimationColorPropertyPath = { - [K in AnimationPropertyPath]: AnimationValueForPath extends string ? K : never; -}[AnimationPropertyPath]; +export type AnimationColorPropertyPath = Extract< + AnimationPropertyPath, + "color" | "background.color" +>; +export type AnimationNumericPropertyPath = Exclude< + AnimationPropertyPath, + AnimationColorPropertyPath +>; export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; export type DiscreteKeyframeInterpolation = "hold"; @@ -70,20 +51,18 @@ export type AnimationInterpolation = | ContinuousKeyframeInterpolation | DiscreteKeyframeInterpolation; -export type PrimitiveAnimationChannelKind = "scalar" | "discrete"; -export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete"; export type ScalarSegmentType = "step" | "linear" | "bezier"; export type TangentMode = "auto" | "aligned" | "broken" | "flat"; export type ChannelExtrapolationMode = "hold" | "linear"; export interface CurveHandle { - dt: number; + dt: MediaTime; dv: number; } -interface BaseAnimationKeyframe { +interface BaseAnimationKeyframe { id: string; - time: number; // relative to element start time + time: MediaTime; // relative to element start time value: TValue; } @@ -94,13 +73,16 @@ export interface ScalarAnimationKey extends BaseAnimationKeyframe { tangentMode: TangentMode; } -export interface DiscreteAnimationKey - extends BaseAnimationKeyframe {} +export type DiscreteAnimationKey = BaseAnimationKeyframe; -export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey; +export type Keyframe = + TValue extends number + ? ScalarAnimationKey + : TValue extends DiscreteValue + ? DiscreteAnimationKey + : never; -export interface ScalarAnimationChannel { - kind: "scalar"; +export interface ScalarChannel { keys: ScalarAnimationKey[]; extrapolation?: { before: ChannelExtrapolationMode; @@ -108,72 +90,26 @@ export interface ScalarAnimationChannel { }; } -export interface DiscreteAnimationChannel { - kind: "discrete"; +export interface DiscreteChannel { keys: DiscreteAnimationKey[]; } -export type AnimationChannel = - | ScalarAnimationChannel - | DiscreteAnimationChannel; +export type Channel = + TValue extends number + ? ScalarChannel + : TValue extends DiscreteValue + ? DiscreteChannel + : never; -export type ElementAnimationChannelMap = Record< - string, - AnimationChannel | undefined ->; +export type ScalarAnimationChannel = Channel; +export type DiscreteAnimationChannel = Channel; +export type AnimationChannel = Channel; -export interface AnimationBindingComponent { - key: TKey; - channelId: string; -} - -interface BaseAnimationBinding< - TKind extends AnimationBindingKind, - TComponentKey extends string, -> { - path: AnimationPath; - kind: TKind; - components: AnimationBindingComponent[]; -} - -export interface NumberAnimationBinding - extends BaseAnimationBinding<"number", "value"> {} - -export interface Vector2AnimationBinding - extends BaseAnimationBinding<"vector2", "x" | "y"> {} - -export interface ColorAnimationBinding - extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> { - colorSpace: "srgb-linear"; -} - -export interface DiscreteAnimationBinding - extends BaseAnimationBinding<"discrete", "value"> {} - -export type AnimationBindingInstance = - | NumberAnimationBinding - | Vector2AnimationBinding - | ColorAnimationBinding - | DiscreteAnimationBinding; - -export interface AnimationBindingByKind { - number: NumberAnimationBinding; - vector2: Vector2AnimationBinding; - color: ColorAnimationBinding; - discrete: DiscreteAnimationBinding; -} - -export type AnimationBindingOfKind = - AnimationBindingByKind[TKind]; - -export type ElementAnimationBindingMap = Record< - string, - AnimationBindingInstance | undefined ->; +export type CompositeChannelData = Record; +export type ChannelData = AnimationChannel | CompositeChannelData; export interface ElementAnimations { - bindings: ElementAnimationBindingMap; - channels: ElementAnimationChannelMap; + [propertyPath: AnimationPath]: ChannelData | undefined; } export type NormalizedCubicBezier = [number, number, number, number]; @@ -181,7 +117,6 @@ export type NormalizedCubicBezier = [number, number, number, number]; export interface ScalarGraphChannelTarget { propertyPath: AnimationPath; componentKey: string; - channelId: string; } export interface ScalarGraphChannel extends ScalarGraphChannelTarget { @@ -209,8 +144,8 @@ export interface ScalarCurveKeyframePatch { export interface ElementKeyframe { propertyPath: AnimationPath; id: string; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation: AnimationInterpolation; } diff --git a/apps/web/src/animation/values.ts b/apps/web/src/animation/values.ts new file mode 100644 index 00000000..7733ec4c --- /dev/null +++ b/apps/web/src/animation/values.ts @@ -0,0 +1,61 @@ +import type { + AnimationColorPropertyPath, + AnimationNumericPropertyPath, + ElementAnimations, +} from "./types"; +import { resolveAnimationPathValueAtTime } from "./resolve"; + +export function resolveOpacityAtTime({ + baseOpacity, + animations, + localTime, +}: { + baseOpacity: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath: "opacity", + localTime: Math.max(0, localTime), + fallbackValue: baseOpacity, + }); +} + +export function resolveNumberAtTime({ + baseValue, + animations, + propertyPath, + localTime, +}: { + baseValue: number; + animations: ElementAnimations | undefined; + propertyPath: AnimationNumericPropertyPath; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseValue, + }); +} + +export function resolveColorAtTime({ + baseColor, + animations, + propertyPath, + localTime, +}: { + baseColor: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationColorPropertyPath; + localTime: number; +}): string { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseColor, + }); +} diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx index a7085154..9728cbc7 100644 --- a/apps/web/src/app/brand/page.tsx +++ b/apps/web/src/app/brand/page.tsx @@ -1,268 +1,268 @@ -"use client"; - -import type { CSSProperties } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { Check, Copy, Download } from "lucide-react"; -import { useState } from "react"; -import { BasePage } from "@/app/base-page"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/utils/ui"; - -function downloadAsset(src: string) { - const filename = src.split("/").pop() ?? "asset.svg"; - const a = document.createElement("a"); - a.href = src; - a.download = filename; - a.click(); -} - -async function copyAsset(src: string) { - const res = await fetch(src); - const text = await res.text(); - await navigator.clipboard.writeText(text); -} - -const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets); - -type AssetTheme = "dark" | "light" | "icon"; - -interface AssetVariant { - src: string; - theme: AssetTheme; - label: string; - width: number; - height: number; -} - -interface AssetSection { - title: string; - description: string; - cols: "1" | "2"; - assets: AssetVariant[]; -} - -const ASSET_SECTIONS: AssetSection[] = [ - { - title: "Symbol", - description: - "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", - cols: "2", - assets: [ - { - src: "/logos/opencut/symbol.svg", - theme: "dark", - label: "Symbol", - width: 400, - height: 400, - }, - { - src: "/logos/opencut/symbol-light.svg", - theme: "light", - label: "Symbol", - width: 400, - height: 400, - }, - ], - }, - { - title: "Lockup", - description: - "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", - cols: "2", - assets: [ - { - src: "/logos/opencut/logo.svg", - theme: "dark", - label: "Logo", - width: 1809, - height: 400, - }, - { - src: "/logos/opencut/logo-light.svg", - theme: "light", - label: "Logo", - width: 1809, - height: 400, - }, - { - src: "/logos/opencut/text.svg", - theme: "dark", - label: "Text", - width: 1760, - height: 400, - }, - { - src: "/logos/opencut/text-light.svg", - theme: "light", - label: "Text", - width: 1760, - height: 400, - }, - ], - }, -]; - -export default function BrandPage() { - return ( - - Download OpenCut brand assets for use in your projects.{" "} - - document - .getElementById("guidelines") - ?.scrollIntoView({ behavior: "smooth" }) - } - > - Read the brand guidelines. - - - } - action={ - - } - > -
- {ASSET_SECTIONS.map((section) => ( -
-
-

{section.title}

-

- {section.description} -

-
-
- {section.assets.map((variant) => ( - - ))} -
-
- ))} -
- - - -
-
-

Usage

-

- OpenCut is open source — the code is free to use under its license. - That license does not cover the name or logo. You can say you use - OpenCut, that your project integrates with OpenCut, or that it was - built on top of OpenCut. You cannot name your product OpenCut, imply - we made or endorse your product, or use the marks commercially - without asking first. For anything unclear, reach out at{" "} - - brand@opencut.app - - . -

-
- -
-

What's not allowed

-
    - {[ - "Using OpenCut in the name of your product, service, or domain.", - "Implying that OpenCut made, sponsors, or endorses your work.", - "Using the logo or name on merchandise or commercial marketing.", - "Modifying the marks.", - ].map((item) => ( -
  • - - - {item} -
  • - ))} -
-
-
-
- ); -} - -const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = { - light: { - backgroundImage: - "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)", - backgroundSize: "18px 18px", - backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", - backgroundColor: "#000", - }, - dark: { - backgroundImage: - "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)", - backgroundSize: "18px 18px", - backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", - backgroundColor: "#f5f5f5", - }, -}; - -function AssetCard({ variant }: { variant: AssetVariant }) { - const [copied, setCopied] = useState(false); - - async function handleCopy() { - await copyAsset(variant.src); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - - return ( - -
- {variant.label} -
- - -
- ); -} +"use client"; + +import type { CSSProperties } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Check, Copy, Download } from "lucide-react"; +import { useState } from "react"; +import { BasePage } from "@/app/base-page"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/utils/ui"; + +function downloadAsset(src: string) { + const filename = src.split("/").pop() ?? "asset.svg"; + const a = document.createElement("a"); + a.href = src; + a.download = filename; + a.click(); +} + +async function copyAsset(src: string) { + const res = await fetch(src); + const text = await res.text(); + await navigator.clipboard.writeText(text); +} + +const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets); + +type AssetTheme = "dark" | "light" | "icon"; + +interface AssetVariant { + src: string; + theme: AssetTheme; + label: string; + width: number; + height: number; +} + +interface AssetSection { + title: string; + description: string; + cols: "1" | "2"; + assets: AssetVariant[]; +} + +const ASSET_SECTIONS: AssetSection[] = [ + { + title: "Symbol", + description: + "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", + cols: "2", + assets: [ + { + src: "/logos/opencut/symbol.svg", + theme: "dark", + label: "Symbol", + width: 400, + height: 400, + }, + { + src: "/logos/opencut/symbol-light.svg", + theme: "light", + label: "Symbol", + width: 400, + height: 400, + }, + ], + }, + { + title: "Lockup", + description: + "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", + cols: "2", + assets: [ + { + src: "/logos/opencut/logo.svg", + theme: "dark", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/logo-light.svg", + theme: "light", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/text.svg", + theme: "dark", + label: "Text", + width: 1760, + height: 400, + }, + { + src: "/logos/opencut/text-light.svg", + theme: "light", + label: "Text", + width: 1760, + height: 400, + }, + ], + }, +]; + +export default function BrandPage() { + return ( + + Download OpenCut brand assets for use in your projects.{" "} + + document + .getElementById("guidelines") + ?.scrollIntoView({ behavior: "smooth" }) + } + > + Read the brand guidelines. + + + } + action={ + + } + > +
+ {ASSET_SECTIONS.map((section) => ( +
+
+

{section.title}

+

+ {section.description} +

+
+
+ {section.assets.map((variant) => ( + + ))} +
+
+ ))} +
+ + + +
+
+

Usage

+

+ OpenCut is open source — the code is free to use under its license. + That license does not cover the name or logo. You can say you use + OpenCut, that your project integrates with OpenCut, or that it was + built on top of OpenCut. You cannot name your product OpenCut, imply + we made or endorse your product, or use the marks commercially + without asking first. For anything unclear, reach out at{" "} + + brand@opencut.app + + . +

+
+ +
+

What's not allowed

+
    + {[ + "Using OpenCut in the name of your product, service, or domain.", + "Implying that OpenCut made, sponsors, or endorses your work.", + "Using the logo or name on merchandise or commercial marketing.", + "Modifying the marks.", + ].map((item) => ( +
  • + - + {item} +
  • + ))} +
+
+
+
+ ); +} + +const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = { + light: { + backgroundImage: + "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)", + backgroundSize: "18px 18px", + backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", + backgroundColor: "#000", + }, + dark: { + backgroundImage: + "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)", + backgroundSize: "18px 18px", + backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", + backgroundColor: "#f5f5f5", + }, +}; + +function AssetCard({ variant }: { variant: AssetVariant }) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + await copyAsset(variant.src); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + +
+ {variant.label} +
+ + +
+ ); +} diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index b3d40a37..680dfff4 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -130,8 +130,14 @@ function EditorLayout() { direction="vertical" className="size-full gap-[0.18rem]" onLayout={(sizes) => { - setPanel("mainContent", sizes[0] ?? panels.mainContent); - setPanel("timeline", sizes[1] ?? panels.timeline); + setPanel({ + panel: "mainContent", + size: sizes[0] ?? panels.mainContent, + }); + setPanel({ + panel: "timeline", + size: sizes[1] ?? panels.timeline, + }); }} > { - setPanel("tools", sizes[0] ?? panels.tools); - setPanel("preview", sizes[1] ?? panels.preview); - setPanel("properties", sizes[2] ?? panels.properties); + setPanel({ panel: "tools", size: sizes[0] ?? panels.tools }); + setPanel({ panel: "preview", size: sizes[1] ?? panels.preview }); + setPanel({ + panel: "properties", + size: sizes[2] ?? panels.properties, + }); }} >
  • You can clear local data from your browser at any time
  • -
  • We don't sell or share your data with anyone (we don't even have it)
  • +
  • + We don't sell or share your data with anyone (we don't + even have it) +
  • Questions? Email us at{" "} @@ -172,7 +175,7 @@ export default function PrivacyPage() { GitHub @@ -189,7 +192,7 @@ export default function PrivacyPage() { GitHub repository @@ -205,7 +208,7 @@ export default function PrivacyPage() { X (Twitter) diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx index 0845892c..5f2e0420 100644 --- a/apps/web/src/app/terms/page.tsx +++ b/apps/web/src/app/terms/page.tsx @@ -51,9 +51,13 @@ export default function TermsPage() { Free for personal and commercial use with no watermarks or restrictions -

  • You're responsible for how you use it - don't break the law
  • - Service provided "as is" - we can't guarantee perfect uptime + You're responsible for how you use it - don't break + the law +
  • +
  • + Service provided "as is" - we can't guarantee + perfect uptime
  • Open source means you can review our code and self-host if @@ -109,8 +113,8 @@ export default function TermsPage() {
  • - You're responsible for how you use OpenCut and the content you create. - Don't use it for anything illegal in your jurisdiction. + You're responsible for how you use OpenCut and the content you + create. Don't use it for anything illegal in your jurisdiction.

    @@ -127,8 +131,8 @@ export default function TermsPage() {

    Service

    OpenCut does not currently require an account. The service is provided - "as is" without warranties. While we strive for reliability, we can't - guarantee uninterrupted service. + "as is" without warranties. While we strive for + reliability, we can't guarantee uninterrupted service.

    @@ -146,7 +150,7 @@ export default function TermsPage() {
    GitHub @@ -161,12 +165,12 @@ export default function TermsPage() { OpenCut is provided free of charge. To the extent permitted by law:

      -
    • We're not liable for any loss of data or content
    • +
    • We're not liable for any loss of data or content
    • Projects are stored in your browser and may be lost if you clear browser data
    • -
    • We're not responsible for how you use the service
    • +
    • We're not responsible for how you use the service
    • Our liability is limited to the maximum extent allowed by law

    @@ -180,7 +184,7 @@ export default function TermsPage() {

    Service Changes

    We may update OpenCut and these terms: