Compare commits

...
Author SHA1 Message Date
Maze Winther 238750c025 docs: refocus readme on rewrite 2026-05-18 17:06:40 +02:00
Senthil Raja RandGitHub 6fdb1559aa feat: display keyboard shortcuts on toolbar buttons (#788)
- Add shortcut hints to toolbar buttons via tooltip text
- Integrate useKeyboardShortcutsHelp hook for consistent shortcut retrieval
- Timeline toolbar: split, duplicate, delete, snapping, bookmark buttons
- Preview toolbar: play/pause button
- Format: tooltip text appended with '(Key)' or '(Key1 or Key2)'
- Gracefully handle actions without defined shortcuts
2026-05-17 04:27:16 +02:00
MazeandGitHub fbe3db74d7 chore: update copyright year 2026-05-06 22:53:51 +02:00
Maze Winther d1f4cb615b docs: fix readme 2026-05-02 16:51:01 +02:00
Maze Winther 19f1086492 Merge branch 'domain-import' 2026-05-02 16:46:13 +02:00
Maze Winther 8ab3aeba7c docs: move NOTES.md file 2026-05-02 16:38:06 +02:00
Maze Winther 6423b352d6 chore: ignore migrations file 2026-05-02 16:26:09 +02:00
Maze Winther 7b942b363d chore: remove file 2026-05-02 03:27:23 +02:00
Maze WintherandCursor 8b8d65b509 refactor: collapse mask definition registry to a single keyed registry
The freeform mask was assembled on every getMaskDefinition("freeform") call,
re-spreading params and injecting a wrong SquareIcon placeholder, with a type
cast to suppress the structural mismatch. Root cause: MasksRegistry was keyed
on BuiltinMaskType, so freeform had no entry.

- Re-key MasksRegistry from BuiltinMaskType to MaskType
- Register freeform alongside builtins in registerDefaultMasks, with
  PenToolAddIcon as its menu icon
- getMaskDefinition and getMaskDefinitionsForMenu become plain registry
  lookups with no branching, no manual spreading, no cast
- Rename RegisteredBuiltinMaskDefinition to RegisteredMaskDefinition and
  tighten computeParamUpdate return type to Partial<BaseMaskParams>

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 03:27:23 +02:00
Maze WintherandCursor 23187b0b6a refactor: remove dead tangent handle variant
The { kind: "tangent" } MaskHandleId variant was never constructed — neither
getFreeformDisplayHandles nor any other code ever produced a tangent handle
object. The in/out control-arm branches in computeFreeformParamUpdate, the
tangent case in maskHandleIdKey, and the CUSTOM_MASK_TANGENT_SIZE rendering
path in mask-handles.tsx were all unreachable. Removed all four dead paths
and removed "tangent" from MaskHandleKind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 03:27:23 +02:00
Maze WintherandCursor f66cdbfeb2 fix: address review findings in masks substrate
- Remove dead ternary in getPreferredEdges (always returned undefined; the
  all-false object was never reached by Object.values().some(Boolean))
- Document why drawWithFeather descriptors zero the feather field in the
  compositor (analytical renderer handles softness itself; zeroing prevents
  a second JFA feather pass)
- Remove legacyType from V30→V31 migration (not consumed anywhere; a
  down-migration can map type:"freeform" back to "custom" directly)
- Add V30→V31 test coverage: skip-guard branches, freeform passthrough,
  elements without a masks array
- Add snap tests for vertical-edge (bottom) and uniform-scale handle paths

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 03:27:23 +02:00
Maze Winther 99137fe493 docs: capture mask rust migration notes
Made-with: Cursor
2026-05-02 03:27:23 +02:00
Maze Winther 79dcec8a2e refactor: split builtin and freeform masks
Made-with: Cursor
2026-05-02 03:27:23 +02:00
Maze Winther 97f8d1968f refactor: type mask handle identifiers
Made-with: Cursor
2026-05-02 03:27:23 +02:00
Maze Winther ae1292a14b refactor: make mask renderer dispatch explicit
Made-with: Cursor
2026-05-02 03:27:23 +02:00
Maze Winther 6ea5a156b6 refactor: split mask snap dispatch
Made-with: Cursor
2026-05-02 03:27:23 +02:00
Maze Winther 401cc35743 refactor: animate params over time 2026-05-02 03:27:23 +02:00
Maze Winther b8d08df244 chore: cleanup 2026-05-02 03:27:23 +02:00
Maze Winther 2ee0a44735 refactor: unify element params 2026-05-02 03:27:23 +02:00
Maze Winther a9b9cf6bf5 chore: switch from biome to eslint + prettier; fix ton of lint issues 2026-05-02 03:27:23 +02:00
Maze Winther d6622dc6b3 chore: untrack cargo-tools files 2026-05-02 03:10:08 +02:00
Maze Winther 6ec818fc11 chore: remove accidental file
Made-with: Cursor
2026-04-26 21:31:12 +02:00
Maze Winther 6daa69635b fix: restore README header, badges, links, and star chart
Made-with: Cursor
2026-04-26 20:51:32 +02:00
Maze Winther 6b1f38a9a3 refactor: split animation helpers by domain 2026-04-26 20:44:07 +02:00
Maze Winther 56ca09692a refactor: thinner hooks 2026-04-26 17:31:28 +02:00
Maze Winther 0b7597b31f refactor: split animation helpers by domain 2026-04-26 04:04:30 +02:00
Maze Winther e6c6193638 fix: linter 2026-04-26 03:06:13 +02:00
Maze Winther 685d902740 fix: follow-up to prev commit 2026-04-26 02:59:30 +02:00
Maze Winther eea6d43c82 fix: model editor time as wasm MediaTime ticks end-to-end 2026-04-26 02:50:04 +02:00
294 changed files with 11906 additions and 9320 deletions
+3 -3
View File
@@ -168,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
2. Make your changes 2. Make your changes
3. Run the relevant checks for the area you touched: 3. Run the relevant checks for the area you touched:
- Web changes: from `apps/web`, run `bun run lint` and `bunx biome format --write .` - Web changes: from `apps/web`, run `bun run lint` and `bun run format`
- Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet - Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet
4. Commit your changes with a descriptive message 4. Commit your changes with a descriptive message
@@ -176,8 +176,8 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
## Code Style ## Code Style
- We use Biome for code formatting and linting - We use ESLint for linting and Prettier for formatting
- Run `bunx biome format --write .` from the `apps/web` directory to format code - Run `bun run format` from the `apps/web` directory to format code
- Run `bun run lint` from the `apps/web` directory to check for linting issues - Run `bun run lint` from the `apps/web` directory to check for linting issues
- Follow the existing code patterns - Follow the existing code patterns
+342 -344
View File
@@ -1,344 +1,342 @@
--- ---
applyTo: "**/*.{ts,tsx,js,jsx}" applyTo: "**/*.{ts,tsx,js,jsx}"
--- ---
# Project Context # Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. Strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects, enforced via ESLint (linting) and Prettier (formatting).
## Key Principles ## Key Principles
- Zero configuration required - Maximum type safety
- Subsecond performance - AI-friendly code generation
- Maximum type safety
- AI-friendly code generation ## Before Writing Code
## Before Writing Code 1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
1. Analyze existing patterns in the codebase 3. Follow the rules below strictly
2. Consider edge cases and error scenarios 4. Validate accessibility requirements
3. Follow the rules below strictly
4. Validate accessibility requirements ## Rules
## Rules ### Accessibility (a11y)
### Accessibility (a11y) - Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements.
- Don't use `accessKey` attribute on any HTML element. - Don't add ARIA roles, states, and properties to elements that don't support them.
- Don't set `aria-hidden="true"` on focusable elements. - Don't use distracting elements like `<marquee>` or `<blink>`.
- Don't add ARIA roles, states, and properties to elements that don't support them. - Only use the `scope` prop on `<th>` elements.
- Don't use distracting elements like `<marquee>` or `<blink>`. - Don't assign non-interactive ARIA roles to interactive HTML elements.
- Only use the `scope` prop on `<th>` elements. - Make sure label elements have text content and are associated with an input.
- Don't assign non-interactive ARIA roles to interactive HTML elements. - Don't assign interactive ARIA roles to non-interactive HTML elements.
- Make sure label elements have text content and are associated with an input. - Don't assign `tabIndex` to non-interactive HTML elements.
- Don't assign interactive ARIA roles to non-interactive HTML elements. - Don't use positive integers for `tabIndex` property.
- Don't assign `tabIndex` to non-interactive HTML elements. - Don't include "image", "picture", or "photo" in img alt prop.
- Don't use positive integers for `tabIndex` property. - Don't use explicit role property that's the same as the implicit/default role.
- Don't include "image", "picture", or "photo" in img alt prop. - Make static elements with click handlers use a valid role attribute.
- Don't use explicit role property that's the same as the implicit/default role. - Always include a `title` element for SVG elements.
- Make static elements with click handlers use a valid role attribute. - Give all elements requiring alt text meaningful information for screen readers.
- Always include a `title` element for SVG elements. - Make sure anchors have content that's accessible to screen readers.
- Give all elements requiring alt text meaningful information for screen readers. - Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
- Make sure anchors have content that's accessible to screen readers. - Include all required ARIA attributes for elements with ARIA roles.
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. - Make sure ARIA properties are valid for the element's supported roles.
- Include all required ARIA attributes for elements with ARIA roles. - Always include a `type` attribute for button elements.
- Make sure ARIA properties are valid for the element's supported roles. - Make elements with interactive roles and handlers focusable.
- Always include a `type` attribute for button elements. - Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
- Make elements with interactive roles and handlers focusable. - Always include a `lang` attribute on the html element.
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). - Always include a `title` attribute for iframe elements.
- Always include a `lang` attribute on the html element. - Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Always include a `title` attribute for iframe elements. - Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. - Include caption tracks for audio and video elements.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. - Use semantic elements instead of role attributes in JSX.
- Include caption tracks for audio and video elements. - Make sure all anchors are valid and navigable.
- Use semantic elements instead of role attributes in JSX. - Ensure all ARIA properties (`aria-*`) are valid.
- Make sure all anchors are valid and navigable. - Use valid, non-abstract ARIA roles for elements with ARIA roles.
- Ensure all ARIA properties (`aria-*`) are valid. - Use valid ARIA state and property values.
- Use valid, non-abstract ARIA roles for elements with ARIA roles. - Use valid values for the `autocomplete` attribute on input elements.
- Use valid ARIA state and property values. - Use correct ISO language/country codes for the `lang` attribute.
- Use valid values for the `autocomplete` attribute on input elements.
- Use correct ISO language/country codes for the `lang` attribute. ### Code Complexity and Quality
### Code Complexity and Quality - Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object.
- Don't use consecutive spaces in regular expression literals. - Don't use primitive type aliases or misleading types.
- Don't use the `arguments` object. - Don't use the comma operator.
- Don't use primitive type aliases or misleading types. - Don't use empty type parameters in type aliases and interfaces.
- Don't use the comma operator. - Don't write functions that exceed a given Cognitive Complexity score.
- Don't use empty type parameters in type aliases and interfaces. - Don't nest describe() blocks too deeply in test files.
- Don't write functions that exceed a given Cognitive Complexity score. - Don't use unnecessary boolean casts.
- Don't nest describe() blocks too deeply in test files. - Don't use unnecessary callbacks with flatMap.
- Don't use unnecessary boolean casts. - Use for...of statements instead of Array.forEach.
- Don't use unnecessary callbacks with flatMap. - Don't create classes that only have static members (like a static namespace).
- Use for...of statements instead of Array.forEach. - Don't use this and super in static contexts.
- Don't create classes that only have static members (like a static namespace). - Don't use unnecessary catch clauses.
- Don't use this and super in static contexts. - Don't use unnecessary constructors.
- Don't use unnecessary catch clauses. - Don't use unnecessary continue statements.
- Don't use unnecessary constructors. - Don't export empty modules that don't change anything.
- Don't use unnecessary continue statements. - Don't use unnecessary escape sequences in regular expression literals.
- Don't export empty modules that don't change anything. - Don't use unnecessary fragments.
- Don't use unnecessary escape sequences in regular expression literals. - Don't use unnecessary labels.
- Don't use unnecessary fragments. - Don't use unnecessary nested block statements.
- Don't use unnecessary labels. - Don't rename imports, exports, and destructured assignments to the same name.
- Don't use unnecessary nested block statements. - Don't use unnecessary string or template literal concatenation.
- Don't rename imports, exports, and destructured assignments to the same name. - Don't use String.raw in template literals when there are no escape sequences.
- Don't use unnecessary string or template literal concatenation. - Don't use useless case statements in switch statements.
- Don't use String.raw in template literals when there are no escape sequences. - Don't use ternary operators when simpler alternatives exist.
- Don't use useless case statements in switch statements. - Don't use useless `this` aliasing.
- Don't use ternary operators when simpler alternatives exist. - Don't use any or unknown as type constraints.
- Don't use useless `this` aliasing. - Don't initialize variables to undefined.
- Don't use any or unknown as type constraints. - Don't use the void operators (they're not familiar).
- Don't initialize variables to undefined. - Use arrow functions instead of function expressions.
- Don't use the void operators (they're not familiar). - Use Date.now() to get milliseconds since the Unix Epoch.
- Use arrow functions instead of function expressions. - Use .flatMap() instead of map().flat() when possible.
- Use Date.now() to get milliseconds since the Unix Epoch. - Use literal property access instead of computed property access.
- Use .flatMap() instead of map().flat() when possible. - Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
- Use literal property access instead of computed property access. - Use concise optional chaining instead of chained logical expressions.
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. - Use regular expression literals instead of the RegExp constructor when possible.
- Use concise optional chaining instead of chained logical expressions. - Don't use number literal object member names that aren't base 10 or use underscore separators.
- Use regular expression literals instead of the RegExp constructor when possible. - Remove redundant terms from logical expressions.
- Don't use number literal object member names that aren't base 10 or use underscore separators. - Use while loops instead of for loops when you don't need initializer and update expressions.
- Remove redundant terms from logical expressions. - Don't pass children as props.
- Use while loops instead of for loops when you don't need initializer and update expressions. - Don't reassign const variables.
- Don't pass children as props. - Don't use constant expressions in conditions.
- Don't reassign const variables. - Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
- Don't use constant expressions in conditions. - Don't return a value from a constructor.
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant. - Don't use empty character classes in regular expression literals.
- Don't return a value from a constructor. - Don't use empty destructuring patterns.
- Don't use empty character classes in regular expression literals. - Don't call global object properties as functions.
- Don't use empty destructuring patterns. - Don't declare functions and vars that are accessible outside their block.
- Don't call global object properties as functions. - Make sure builtins are correctly instantiated.
- Don't declare functions and vars that are accessible outside their block. - Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
- Make sure builtins are correctly instantiated. - Don't use variables and function parameters before they're declared.
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. - Don't use 8 and 9 escape sequences in string literals.
- Don't use variables and function parameters before they're declared. - Don't use literal numbers that lose precision.
- Don't use 8 and 9 escape sequences in string literals.
- Don't use literal numbers that lose precision. ### React and JSX Best Practices
### React and JSX Best Practices - Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks.
- Don't use the return value of React.render. - Make sure all React hooks are called from the top level of component functions.
- Make sure all dependencies are correctly specified in React hooks. - Don't forget key props in iterators and collection literals.
- Make sure all React hooks are called from the top level of component functions. - Don't destructure props inside JSX components in Solid projects.
- Don't forget key props in iterators and collection literals. - Don't define React components inside other components.
- Don't destructure props inside JSX components in Solid projects. - Don't use event handlers on non-interactive elements.
- Don't define React components inside other components. - Don't assign to React component props.
- Don't use event handlers on non-interactive elements. - Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't assign to React component props. - Don't use dangerous JSX props.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. - Don't use Array index in keys.
- Don't use dangerous JSX props. - Don't insert comments as text nodes.
- Don't use Array index in keys. - Don't assign JSX properties multiple times.
- Don't insert comments as text nodes. - Don't add extra closing tags for components without children.
- Don't assign JSX properties multiple times. - Use `<>...</>` instead of `<Fragment>...</Fragment>`.
- Don't add extra closing tags for components without children. - Watch out for possible "wrong" semicolons inside JSX elements.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
- Watch out for possible "wrong" semicolons inside JSX elements. ### Correctness and Safety
### Correctness and Safety - Don't assign a value to itself.
- Don't return a value from a setter.
- Don't assign a value to itself. - Don't compare expressions that modify string case with non-compliant values.
- Don't return a value from a setter. - Don't use lexical declarations in switch clauses.
- Don't compare expressions that modify string case with non-compliant values. - Don't use variables that haven't been declared in the document.
- Don't use lexical declarations in switch clauses. - Don't write unreachable code.
- Don't use variables that haven't been declared in the document. - 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 write unreachable code. - Don't use control flow statements in finally blocks.
- 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 optional chaining where undefined values aren't allowed.
- Don't use control flow statements in finally blocks. - Don't have unused function parameters.
- Don't use optional chaining where undefined values aren't allowed. - Don't have unused imports.
- Don't have unused function parameters. - Don't have unused labels.
- Don't have unused imports. - Don't have unused private class members.
- Don't have unused labels. - Don't have unused variables.
- Don't have unused private class members. - Make sure void (self-closing) elements don't have children.
- Don't have unused variables. - Don't return a value from a function with the return type 'void'
- Make sure void (self-closing) elements don't have children. - Use isNaN() when checking for NaN.
- Don't return a value from a function with the return type 'void' - Make sure "for" loop update clauses move the counter in the right direction.
- Use isNaN() when checking for NaN. - Make sure typeof expressions are compared to valid values.
- Make sure "for" loop update clauses move the counter in the right direction. - Make sure generator functions contain yield.
- Make sure typeof expressions are compared to valid values. - Don't use await inside loops.
- Make sure generator functions contain yield. - Don't use bitwise operators.
- Don't use await inside loops. - Don't use expressions where the operation doesn't change the value.
- Don't use bitwise operators. - Make sure Promise-like statements are handled appropriately.
- Don't use expressions where the operation doesn't change the value. - Don't use **dirname and **filename in the global scope.
- Make sure Promise-like statements are handled appropriately. - Prevent import cycles.
- Don't use **dirname and **filename in the global scope. - Don't use configured elements.
- Prevent import cycles. - Don't hardcode sensitive data like API keys and tokens.
- Don't use configured elements. - Don't let variable declarations shadow variables from outer scopes.
- Don't hardcode sensitive data like API keys and tokens. - Don't use the TypeScript directive @ts-ignore.
- Don't let variable declarations shadow variables from outer scopes. - Prevent duplicate polyfills from Polyfill.io.
- Don't use the TypeScript directive @ts-ignore. - Don't use useless backreferences in regular expressions that always match empty strings.
- Prevent duplicate polyfills from Polyfill.io. - Don't use unnecessary escapes in string literals.
- Don't use useless backreferences in regular expressions that always match empty strings. - Don't use useless undefined.
- Don't use unnecessary escapes in string literals. - Make sure getters and setters for the same property are next to each other in class and object definitions.
- Don't use useless undefined. - Make sure object literals are declared consistently (defaults to explicit definitions).
- Make sure getters and setters for the same property are next to each other in class and object definitions. - Use static Response methods instead of new Response() constructor when possible.
- Make sure object literals are declared consistently (defaults to explicit definitions). - Make sure switch-case statements are exhaustive.
- Use static Response methods instead of new Response() constructor when possible. - Make sure the `preconnect` attribute is used when using Google Fonts.
- Make sure switch-case statements are exhaustive. - Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
- Make sure the `preconnect` attribute is used when using Google Fonts. - Make sure iterable callbacks return consistent values.
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. - Use `with { type: "json" }` for JSON module imports.
- Make sure iterable callbacks return consistent values. - Use numeric separators in numeric literals.
- Use `with { type: "json" }` for JSON module imports. - Use object spread instead of `Object.assign()` when constructing new objects.
- Use numeric separators in numeric literals. - Always use the radix argument when using `parseInt()`.
- Use object spread instead of `Object.assign()` when constructing new objects. - Make sure JSDoc comment lines start with a single asterisk, except for the first one.
- Always use the radix argument when using `parseInt()`. - Include a description parameter for `Symbol()`.
- Make sure JSDoc comment lines start with a single asterisk, except for the first one. - Don't use spread (`...`) syntax on accumulators.
- Include a description parameter for `Symbol()`. - Don't use the `delete` operator.
- Don't use spread (`...`) syntax on accumulators. - Don't access namespace imports dynamically.
- Don't use the `delete` operator. - Don't use namespace imports.
- Don't access namespace imports dynamically. - Declare regex literals at the top level.
- Don't use namespace imports. - Don't use `target="_blank"` without `rel="noopener"`.
- Declare regex literals at the top level.
- Don't use `target="_blank"` without `rel="noopener"`. ### TypeScript Best Practices
### TypeScript Best Practices - Don't use TypeScript enums.
- Don't export imported variables.
- Don't use TypeScript enums. - Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
- Don't export imported variables. - Don't use TypeScript namespaces.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. - Don't use non-null assertions with the `!` postfix operator.
- Don't use TypeScript namespaces. - Don't use parameter properties in class constructors.
- Don't use non-null assertions with the `!` postfix operator. - Don't use user-defined types.
- Don't use parameter properties in class constructors. - Use `as const` instead of literal types and type annotations.
- Don't use user-defined types. - Use either `T[]` or `Array<T>` consistently.
- Use `as const` instead of literal types and type annotations. - Initialize each enum member value explicitly.
- Use either `T[]` or `Array<T>` consistently. - Use `export type` for types.
- Initialize each enum member value explicitly. - Use `import type` for types.
- Use `export type` for types. - Make sure all enum members are literal values.
- Use `import type` for types. - Don't use TypeScript const enum.
- Make sure all enum members are literal values. - Don't declare empty interfaces.
- Don't use TypeScript const enum. - Don't let variables evolve into any type through reassignments.
- Don't declare empty interfaces. - Don't use the any type.
- Don't let variables evolve into any type through reassignments. - Don't misuse the non-null assertion operator (!) in TypeScript files.
- Don't use the any type. - Don't use implicit any type on variable declarations.
- Don't misuse the non-null assertion operator (!) in TypeScript files. - Don't merge interfaces and classes unsafely.
- Don't use implicit any type on variable declarations. - Don't use overload signatures that aren't next to each other.
- Don't merge interfaces and classes unsafely. - Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
- 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
### Style and Consistency - Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks.
- Don't use global `eval()`. - Don't use negation in `if` statements that have `else` clauses.
- Don't use callbacks in asynchronous tests and hooks. - Don't use nested ternary expressions.
- Don't use negation in `if` statements that have `else` clauses. - Don't reassign function parameters.
- Don't use nested ternary expressions. - This rule lets you specify global variable names you don't want to use in your application.
- Don't reassign function parameters. - Don't use specified modules when loaded by import or require.
- This rule lets you specify global variable names you don't want to use in your application. - Don't use constants whose value is the upper-case version of their name.
- Don't use specified modules when loaded by import or require. - Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use constants whose value is the upper-case version of their name. - Don't use template literals if you don't need interpolation or special-character handling.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`. - Don't use `else` blocks when the `if` block breaks early.
- Don't use template literals if you don't need interpolation or special-character handling. - Don't use yoda expressions.
- Don't use `else` blocks when the `if` block breaks early. - Don't use Array constructors.
- Don't use yoda expressions. - Use `at()` instead of integer index access.
- Don't use Array constructors. - Follow curly brace conventions.
- Use `at()` instead of integer index access. - Use `else if` instead of nested `if` statements in `else` clauses.
- Follow curly brace conventions. - Use single `if` statements instead of nested `if` clauses.
- Use `else if` instead of nested `if` statements in `else` clauses. - Use `new` for all builtins except `String`, `Number`, and `Boolean`.
- Use single `if` statements instead of nested `if` clauses. - Use consistent accessibility modifiers on class properties and methods.
- Use `new` for all builtins except `String`, `Number`, and `Boolean`. - Use `const` declarations for variables that are only assigned once.
- Use consistent accessibility modifiers on class properties and methods. - Put default function parameters and optional function parameters last.
- Use `const` declarations for variables that are only assigned once. - Include a `default` clause in switch statements.
- Put default function parameters and optional function parameters last. - Use the `**` operator instead of `Math.pow`.
- Include a `default` clause in switch statements. - Use `for-of` loops when you need the index to extract an item from the iterated array.
- Use the `**` operator instead of `Math.pow`. - Use `node:assert/strict` over `node:assert`.
- Use `for-of` loops when you need the index to extract an item from the iterated array. - Use the `node:` protocol for Node.js builtin modules.
- Use `node:assert/strict` over `node:assert`. - Use Number properties instead of global ones.
- Use the `node:` protocol for Node.js builtin modules. - Use assignment operator shorthand where possible.
- Use Number properties instead of global ones. - Use function types instead of object types with call signatures.
- Use assignment operator shorthand where possible. - Use template literals over string concatenation.
- Use function types instead of object types with call signatures. - Use `new` when throwing an error.
- Use template literals over string concatenation. - Don't throw non-Error values.
- Use `new` when throwing an error. - Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
- Don't throw non-Error values. - Use standard constants instead of approximated literals.
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. - Don't assign values in expressions.
- Use standard constants instead of approximated literals. - Don't use async functions as Promise executors.
- Don't assign values in expressions. - Don't reassign exceptions in catch clauses.
- Don't use async functions as Promise executors. - Don't reassign class members.
- Don't reassign exceptions in catch clauses. - Don't compare against -0.
- Don't reassign class members. - Don't use labeled statements that aren't loops.
- Don't compare against -0. - Don't use void type outside of generic or return types.
- Don't use labeled statements that aren't loops. - Don't use console.
- Don't use void type outside of generic or return types. - Don't use control characters and escape sequences that match control characters in regular expression literals.
- Don't use console. - Don't use debugger.
- Don't use control characters and escape sequences that match control characters in regular expression literals. - Don't assign directly to document.cookie.
- Don't use debugger. - Use `===` and `!==`.
- Don't assign directly to document.cookie. - Don't use duplicate case labels.
- Use `===` and `!==`. - Don't use duplicate class members.
- Don't use duplicate case labels. - Don't use duplicate conditions in if-else-if chains.
- Don't use duplicate class members. - Don't use two keys with the same name inside objects.
- Don't use duplicate conditions in if-else-if chains. - Don't use duplicate function parameter names.
- Don't use two keys with the same name inside objects. - Don't have duplicate hooks in describe blocks.
- Don't use duplicate function parameter names. - Don't use empty block statements and static blocks.
- Don't have duplicate hooks in describe blocks. - Don't let switch clauses fall through.
- Don't use empty block statements and static blocks. - Don't reassign function declarations.
- Don't let switch clauses fall through. - Don't allow assignments to native objects and read-only global variables.
- Don't reassign function declarations. - Use Number.isFinite instead of global isFinite.
- Don't allow assignments to native objects and read-only global variables. - Use Number.isNaN instead of global isNaN.
- Use Number.isFinite instead of global isFinite. - Don't assign to imported bindings.
- Use Number.isNaN instead of global isNaN. - Don't use irregular whitespace characters.
- Don't assign to imported bindings. - Don't use labels that share a name with a variable.
- Don't use irregular whitespace characters. - Don't use characters made with multiple code points in character class syntax.
- Don't use labels that share a name with a variable. - Make sure to use new and constructor properly.
- Don't use characters made with multiple code points in character class syntax. - Don't use shorthand assign when the variable appears on both sides.
- Make sure to use new and constructor properly. - Don't use octal escape sequences in string literals.
- Don't use shorthand assign when the variable appears on both sides. - Don't use Object.prototype builtins directly.
- Don't use octal escape sequences in string literals. - Don't redeclare variables, functions, classes, and types in the same scope.
- Don't use Object.prototype builtins directly. - Don't have redundant "use strict".
- Don't redeclare variables, functions, classes, and types in the same scope. - Don't compare things where both sides are exactly the same.
- Don't have redundant "use strict". - Don't let identifiers shadow restricted names.
- Don't compare things where both sides are exactly the same. - Don't use sparse arrays (arrays with holes).
- Don't let identifiers shadow restricted names. - Don't use template literal placeholder syntax in regular strings.
- Don't use sparse arrays (arrays with holes). - Don't use the then property.
- Don't use template literal placeholder syntax in regular strings. - Don't use unsafe negation.
- Don't use the then property. - Don't use var.
- Don't use unsafe negation. - Don't use with statements in non-strict contexts.
- Don't use var. - Make sure async functions actually use await.
- Don't use with statements in non-strict contexts. - Make sure default clauses in switch statements come last.
- Make sure async functions actually use await. - Make sure to pass a message value when creating a built-in error.
- Make sure default clauses in switch statements come last. - Make sure get methods always return a value.
- Make sure to pass a message value when creating a built-in error. - Use a recommended display strategy with Google Fonts.
- Make sure get methods always return a value. - Make sure for-in loops include an if statement.
- Use a recommended display strategy with Google Fonts. - Use Array.isArray() instead of instanceof Array.
- Make sure for-in loops include an if statement. - Make sure to use the digits argument with Number#toFixed().
- Use Array.isArray() instead of instanceof Array. - Make sure to use the "use strict" directive in script files.
- 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
### Next.js Specific Rules - Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
- Don't use `<img>` elements in Next.js projects. - Don't import next/document outside of pages/\_document.jsx in Next.js projects.
- Don't use `<head>` elements in Next.js projects. - Don't use the next/head module in pages/\_document.js on 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
### Testing Best Practices - Don't use export or module.exports in test files.
- Don't use focused tests.
- Don't use export or module.exports in test files. - Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use focused tests. - Don't use disabled tests.
- Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests. ## Common Tasks
## Common Tasks - `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite format` - Format and fix code automatically
- `npx ultracite init` - Initialize Ultracite in your project - `npx ultracite lint` - Check for issues without fixing
- `npx ultracite format` - Format and fix code automatically
- `npx ultracite lint` - Check for issues without fixing ## Example: Error Handling
## Example: Error Handling ```typescript
// ✅ Good: Comprehensive error handling
```typescript try {
// ✅ Good: Comprehensive error handling const result = await fetchData();
try { return { success: true, data: result };
const result = await fetchData(); } catch (error) {
return { success: true, data: result }; console.error("API call failed:", error);
} catch (error) { return { success: false, error: error.message };
console.error("API call failed:", error); }
return { success: false, error: error.message };
} // ❌ Bad: Swallowing errors
try {
// ❌ Bad: Swallowing errors return await fetchData();
try { } catch (e) {
return await fetchData(); console.log(e);
} catch (e) { }
console.log(e); ```
}
```
+3 -1
View File
@@ -23,4 +23,6 @@ bun.lockb
target/ target/
.cargo-tools/ .cargo-tools/
rust-migration-notes.md
+6
View File
@@ -0,0 +1,6 @@
.next
node_modules
dist
build
target
rust/wasm/pkg
+3
View File
@@ -0,0 +1,3 @@
{
"useTabs": true
}
+2 -9
View File
@@ -7,14 +7,7 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnPaste": true, "editor.formatOnPaste": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit", "source.fixAll.eslint": "explicit"
"source.organizeImports.biome": "explicit"
}, },
"emmet.showExpandedAbbreviation": "never", "emmet.showExpandedAbbreviation": "never"
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
} }
-14
View File
@@ -21,17 +21,3 @@ Each app is a frontend that calls into Rust. Logic is never duplicated between a
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them. - Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
### TypeScript
Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter:
```tsx
// ❌ meaning depends on order; the shape can't evolve without touching every caller
function formatTime(seconds: number, fps: number) { ... }
// ✅ each argument names itself; fields can be added, reordered, or made optional freely
function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
```
The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply.
Generated
+1 -1
View File
@@ -3820,7 +3820,7 @@ dependencies = [
[[package]] [[package]]
name = "opencut-wasm" name = "opencut-wasm"
version = "0.2.9" version = "0.2.10"
dependencies = [ dependencies = [
"bridge", "bridge",
"compositor", "compositor",
+2 -2
View File
@@ -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: 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 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. 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.
+44 -167
View File
@@ -1,167 +1,44 @@
<table width="100%"> <table width="100%">
<tr> <tr>
<td align="left" width="120"> <td align="left" width="120">
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" /> <img src="https://assets.opencut.app/branding/symbol.svg" alt="OpenCut Logo" width="100" />
</td> </td>
<td align="right"> <td align="right">
<h1>OpenCut</h1> <h1>OpenCut</h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3> <h3 style="margin-top: -10px;">A free and open source video editor for web, desktop, and mobile.</h3>
</td> </td>
</tr> </tr>
</table> </table>
## Sponsors [![Discord](https://img.shields.io/discord/1386309140057690133?label=Discord&logo=discord&logoColor=fff&color=5865F2&style=flat)](https://discord.gg/zmR9N35cjK)
[![X](https://img.shields.io/badge/follow-%40opencutapp-000?logo=x&logoColor=fff&style=flat)](https://x.com/opencutapp)
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. [![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat)](LICENSE)
<a href="https://vercel.com/oss"> ## Status
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a> **OpenCut is being rewritten from the ground up.** What's coming:
<a href="https://fal.ai"> - An Editor API
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" /> - First-class third party plugins (made possible by a plugin-first architecture)
</a> - Desktop, mobile, and browser from one codebase (Rust core)
- MCP server (for AI agents)
## Why? - Headless mode (automation, batch rendering)
- A scripting tab directly in the editor
- **Privacy**: Your videos stay on your device
- **Free features**: Most basic CapCut features are now paywalled You can still find the previous version at [opencut-app/opencut-classic](https://github.com/opencut-app/opencut-classic), which is the one to reach for today. [opencut.app](https://opencut.app) still runs the classic version; the rewrite will live at [new.opencut.app](https://new.opencut.app) until it's ready to take over.
- **Simple**: People want editors that are easy to use - CapCut proved that
## Sponsors
## Project Structure
OpenCut is supported by companies that believe in open source creator tools.
- `apps/web/`: Next.js web application
- `apps/desktop/`: Native desktop app built with GPUI (in progress) - [**fal.ai**](https://fal.ai?utm_source=github-opencut&utm_campaign=oss): Generative image, video, and audio models all in one place.
- `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 Want your logo here? Reach out at [sponsor@opencut.app](mailto:sponsor@opencut.app).
## Getting Started ## Contributing
### Prerequisites We're not set up to take outside contributions yet while the architecture is being designed. If you want to follow along, ask questions, or just hang out, [join the Discord](https://discord.gg/zmR9N35cjK) or [open an issue](https://github.com/opencut-app/opencut/issues).
- [Bun](https://bun.sh/docs/installation) ## License
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
[MIT](LICENSE)
> **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)
+4 -4
View File
@@ -9,9 +9,9 @@
"start": "next start", "start": "next start",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"lint": "biome check src/", "lint": "eslint src --ext .ts,.tsx",
"lint:fix": "biome check src/ --write", "lint:fix": "eslint src --ext .ts,.tsx --fix",
"format": "biome format src/ --write", "format": "prettier src --write",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push", "db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
@@ -52,7 +52,7 @@
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "16.1.3", "next": "16.1.3",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"opencut-wasm": "^0.2.9", "opencut-wasm": "^0.2.10",
"pg": "^8.16.2", "pg": "^8.16.2",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
-3
View File
@@ -1,3 +0,0 @@
<svg width="588" height="512" viewBox="0 0 588 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M294 0L588 512H0L294 0Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 159 B

@@ -51,10 +51,10 @@ export function ShortcutsDialog({
const keyString = getKeybindingString(e); const keyString = getKeybindingString(e);
if (keyString) { if (keyString) {
const conflict = validateKeybinding( const conflict = validateKeybinding({
keyString, key: keyString,
recordingShortcut.action, action: recordingShortcut.action,
); });
if (conflict) { if (conflict) {
toast.error( toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`, `Key "${keyString}" is already bound to "${conflict.existingAction}"`,
@@ -68,7 +68,10 @@ export function ShortcutsDialog({
removeKeybinding(key); removeKeybinding(key);
} }
updateKeybinding(keyString, recordingShortcut.action); updateKeybinding({
key: keyString,
action: recordingShortcut.action,
});
setIsRecording(false); setIsRecording(false);
setRecordingShortcut(null); setRecordingShortcut(null);
+38 -37
View File
@@ -1,7 +1,4 @@
import type { import type { ShortcutKey } from "@/actions/keybinding";
KeybindingConfig,
ShortcutKey,
} from "@/actions/keybinding";
import type { TActionWithOptionalArgs } from "./types"; import type { TActionWithOptionalArgs } from "./types";
export type TActionCategory = export type TActionCategory =
@@ -155,33 +152,36 @@ export const ACTIONS = {
export type TAction = keyof typeof ACTIONS; export type TAction = keyof typeof ACTIONS;
const ACTION_DEFAULT_SHORTCUTS = { const ACTION_DEFAULT_SHORTCUTS = [
"toggle-play": ["space", "k"], ["toggle-play", ["space", "k"]],
"seek-forward": ["l"], ["seek-forward", ["l"]],
"seek-backward": ["j"], ["seek-backward", ["j"]],
"frame-step-forward": ["right"], ["frame-step-forward", ["right"]],
"frame-step-backward": ["left"], ["frame-step-backward", ["left"]],
"jump-forward": ["shift+right"], ["jump-forward", ["shift+right"]],
"jump-backward": ["shift+left"], ["jump-backward", ["shift+left"]],
"goto-start": ["home", "enter"], ["goto-start", ["home", "enter"]],
"goto-end": ["end"], ["goto-end", ["end"]],
split: ["s"], ["split", ["s"]],
"split-left": ["q"], ["split-left", ["q"]],
"split-right": ["w"], ["split-right", ["w"]],
"delete-selected": ["backspace", "delete"], ["delete-selected", ["backspace", "delete"]],
"copy-selected": ["ctrl+c"], ["copy-selected", ["ctrl+c"]],
"paste-copied": ["ctrl+v"], ["paste-copied", ["ctrl+v"]],
"toggle-snapping": ["n"], ["toggle-snapping", ["n"]],
"select-all": ["ctrl+a"], ["select-all", ["ctrl+a"]],
"cancel-interaction": ["escape"], ["cancel-interaction", ["escape"]],
"duplicate-selected": ["ctrl+d"], ["duplicate-selected", ["ctrl+d"]],
undo: ["ctrl+z"], ["undo", ["ctrl+z"]],
redo: ["ctrl+shift+z", "ctrl+y"], ["redo", ["ctrl+shift+z", "ctrl+y"]],
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>; ] as const satisfies ReadonlyArray<
readonly [TActionWithOptionalArgs, readonly ShortcutKey[]]
>;
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map<
Record<TAction, readonly ShortcutKey[]> TAction,
> = ACTION_DEFAULT_SHORTCUTS; readonly ShortcutKey[]
>(ACTION_DEFAULT_SHORTCUTS);
export function getActionDefinition({ export function getActionDefinition({
action, action,
@@ -190,18 +190,19 @@ export function getActionDefinition({
}): TActionDefinition { }): TActionDefinition {
return { return {
...ACTIONS[action], ...ACTIONS[action],
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action], defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action),
}; };
} }
export function getDefaultShortcuts(): KeybindingConfig { export function getDefaultShortcuts(): Map<
const shortcuts: KeybindingConfig = {}; ShortcutKey,
TActionWithOptionalArgs
> {
const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>();
for (const [action, defaultShortcuts] of Object.entries( for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) {
ACTION_DEFAULT_SHORTCUTS,
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
for (const shortcut of defaultShortcuts) { for (const shortcut of defaultShortcuts) {
shortcuts[shortcut] = action; shortcuts.set(shortcut, action);
} }
} }
+43 -79
View File
@@ -1,79 +1,43 @@
import type { TActionWithOptionalArgs } from "./types"; import type { TActionWithOptionalArgs } from "./types";
/** /**
* Alt is also regarded as macOS OPTION (⌥) key * 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!) * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
*/ */
export type ModifierKeys = export type ModifierKeys =
| "ctrl" | "ctrl"
| "alt" | "alt"
| "shift" | "shift"
| "ctrl+shift" | "ctrl+shift"
| "alt+shift" | "alt+shift"
| "ctrl+alt" | "ctrl+alt"
| "ctrl+alt+shift"; | "ctrl+alt+shift";
export type Key = const KEYS = [
| "a" "a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
| "b" "k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
| "c" "u", "v", "w", "x", "y", "z",
| "d" "0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
| "e" "up", "down", "left", "right",
| "f" "/", "?", ".",
| "g" "enter", "tab", "space", "escape", "esc",
| "h" "backspace", "delete", "home", "end",
| "i" ] as const;
| "j"
| "k" export type Key = (typeof KEYS)[number];
| "l"
| "m" const KEY_SET: ReadonlySet<string> = new Set(KEYS);
| "n"
| "o" export function isKey(value: string): value is Key {
| "p" return KEY_SET.has(value);
| "q" }
| "r"
| "s" export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
| "t" // Singular keybindings (these will be disabled when an input-ish area has been focused)
| "u" export type SingleCharacterShortcutKey = `${Key}`;
| "v"
| "w" export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
| "x"
| "y" export type KeybindingConfig = {
| "z" [key in ShortcutKey]?: TActionWithOptionalArgs;
| "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;
};
+109 -86
View File
@@ -6,11 +6,15 @@ import type { TActionWithOptionalArgs } from "@/actions";
import { getDefaultShortcuts } from "@/actions"; import { getDefaultShortcuts } from "@/actions";
import { isTypableDOMElement } from "@/utils/browser"; import { isTypableDOMElement } from "@/utils/browser";
import { isAppleDevice } from "@/utils/platform"; import { isAppleDevice } from "@/utils/platform";
import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; import type {
Key,
KeybindingConfig,
ModifierKeys,
ShortcutKey,
} from "@/actions/keybinding";
import { isKey } from "@/actions/keybinding";
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
export interface KeybindingConflict { export interface KeybindingConflict {
key: ShortcutKey; key: ShortcutKey;
existingAction: TActionWithOptionalArgs; existingAction: TActionWithOptionalArgs;
@@ -18,38 +22,57 @@ export interface KeybindingConflict {
} }
interface KeybindingsState { interface KeybindingsState {
keybindings: KeybindingConfig; keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
isCustomized: boolean; isCustomized: boolean;
overlayDepth: number; overlayDepth: number;
openOverlayIds: string[]; openOverlayIds: string[];
isLoadingProject: boolean; isLoadingProject: boolean;
isRecording: boolean; isRecording: boolean;
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; updateKeybinding: (params: {
key: ShortcutKey;
action: TActionWithOptionalArgs;
}) => void;
removeKeybinding: (key: ShortcutKey) => void; removeKeybinding: (key: ShortcutKey) => void;
resetToDefaults: () => void; resetToDefaults: () => void;
importKeybindings: (config: KeybindingConfig) => void; importKeybindings: (config: KeybindingConfig) => void;
exportKeybindings: () => KeybindingConfig; exportKeybindings: () => Record<string, TActionWithOptionalArgs>;
openOverlay: (overlayId: string) => void; openOverlay: (overlayId: string) => void;
closeOverlay: (overlayId: string) => void; closeOverlay: (overlayId: string) => void;
setLoadingProject: (loading: boolean) => void; setLoadingProject: (loading: boolean) => void;
setIsRecording: (isRecording: boolean) => void; setIsRecording: (isRecording: boolean) => void;
validateKeybinding: ( validateKeybinding: (params: {
key: ShortcutKey, key: ShortcutKey;
action: TActionWithOptionalArgs, action: TActionWithOptionalArgs;
) => KeybindingConflict | null; }) => KeybindingConflict | null;
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
} }
type PersistedState = {
keybindings: Record<string, TActionWithOptionalArgs>;
isCustomized: boolean;
};
function isDOMElement(element: EventTarget | null): element is HTMLElement { function isDOMElement(element: EventTarget | null): element is HTMLElement {
return element instanceof HTMLElement; return element instanceof HTMLElement;
} }
function isPersistedState(value: unknown): value is PersistedState {
if (!value || typeof value !== "object") return false;
if (!("keybindings" in value) || !("isCustomized" in value)) return false;
const { keybindings, isCustomized } = value;
return (
typeof keybindings === "object" &&
keybindings !== null &&
typeof isCustomized === "boolean"
);
}
export const useKeybindingsStore = create<KeybindingsState>()( export const useKeybindingsStore = create<KeybindingsState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
keybindings: { ...defaultKeybindings }, keybindings: getDefaultShortcuts(),
isCustomized: false, isCustomized: false,
overlayDepth: 0, overlayDepth: 0,
openOverlayIds: [], openOverlayIds: [],
@@ -61,10 +84,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
const openOverlayIds = s.openOverlayIds.includes(overlayId) const openOverlayIds = s.openOverlayIds.includes(overlayId)
? s.openOverlayIds ? s.openOverlayIds
: [...s.openOverlayIds, overlayId]; : [...s.openOverlayIds, overlayId];
const nextOverlayDepth = openOverlayIds.length;
return { return {
openOverlayIds, openOverlayIds,
overlayDepth: nextOverlayDepth, overlayDepth: openOverlayIds.length,
}; };
}), }),
closeOverlay: (overlayId) => closeOverlay: (overlayId) =>
@@ -72,35 +94,32 @@ export const useKeybindingsStore = create<KeybindingsState>()(
const openOverlayIds = s.openOverlayIds.filter( const openOverlayIds = s.openOverlayIds.filter(
(id) => id !== overlayId, (id) => id !== overlayId,
); );
const nextOverlayDepth = openOverlayIds.length;
return { return {
openOverlayIds, openOverlayIds,
overlayDepth: nextOverlayDepth, overlayDepth: openOverlayIds.length,
}; };
}), }),
setLoadingProject: (loading) => { setLoadingProject: (loading) => {
set({ isLoadingProject: loading }); set({ isLoadingProject: loading });
}, },
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { updateKeybinding: ({ key, action }) => {
set((state) => { set((state) => {
const newKeybindings = { ...state.keybindings }; const next = new Map(state.keybindings);
newKeybindings[key] = action; next.set(key, action);
return { return {
keybindings: newKeybindings, keybindings: next,
isCustomized: true, isCustomized: true,
}; };
}); });
}, },
removeKeybinding: (key: ShortcutKey) => { removeKeybinding: (key) => {
set((state) => { set((state) => {
const newKeybindings = { ...state.keybindings }; const next = new Map(state.keybindings);
delete newKeybindings[key]; next.delete(key);
return { return {
keybindings: newKeybindings, keybindings: next,
isCustomized: true, isCustomized: true,
}; };
}); });
@@ -108,34 +127,35 @@ export const useKeybindingsStore = create<KeybindingsState>()(
resetToDefaults: () => { resetToDefaults: () => {
set({ set({
keybindings: { ...defaultKeybindings }, keybindings: getDefaultShortcuts(),
isCustomized: false, isCustomized: false,
}); });
}, },
importKeybindings: (config: KeybindingConfig) => { importKeybindings: (config) => {
for (const [key] of Object.entries(config)) { const next = new Map<ShortcutKey, TActionWithOptionalArgs>();
for (const [key, action] of Object.entries(config)) {
if (typeof key !== "string" || key.length === 0) { if (typeof key !== "string" || key.length === 0) {
throw new Error(`Invalid key format: ${key}`); throw new Error(`Invalid key format: ${key}`);
} }
if (action !== undefined) {
// Public type's keys are `ShortcutKey`; trust the caller's typing.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
next.set(key as ShortcutKey, action);
}
} }
set({ set({
keybindings: { ...config }, keybindings: next,
isCustomized: true, isCustomized: true,
}); });
}, },
exportKeybindings: () => { exportKeybindings: () => {
return get().keybindings; return Object.fromEntries(get().keybindings);
}, },
validateKeybinding: ( validateKeybinding: ({ key, action }) => {
key: ShortcutKey, const existingAction = get().keybindings.get(key);
action: TActionWithOptionalArgs,
) => {
const { keybindings } = get();
const existingAction = keybindings[key];
if (existingAction && existingAction !== action) { if (existingAction && existingAction !== action) {
return { return {
key, key,
@@ -143,33 +163,45 @@ export const useKeybindingsStore = create<KeybindingsState>()(
newAction: action, newAction: action,
}; };
} }
return null; return null;
}, },
setIsRecording: (isRecording: boolean) => { setIsRecording: (isRecording) => {
set({ isRecording }); set({ isRecording });
}, },
getKeybindingsForAction: (action: TActionWithOptionalArgs) => { getKeybindingsForAction: (action) => {
const { keybindings } = get(); const result: ShortcutKey[] = [];
return Object.keys(keybindings).filter( for (const [key, mapped] of get().keybindings) {
(key) => keybindings[key as ShortcutKey] === action, if (mapped === action) result.push(key);
) as ShortcutKey[]; }
return result;
}, },
getKeybindingString: (ev: KeyboardEvent) => { getKeybindingString: (ev) => generateKeybindingString(ev),
return generateKeybindingString(ev) as ShortcutKey | null;
},
}), }),
{ {
name: "opencut-keybindings", name: "opencut-keybindings",
version: CURRENT_VERSION, version: CURRENT_VERSION,
partialize: (state) => ({ partialize: (state): PersistedState => ({
keybindings: state.keybindings, keybindings: Object.fromEntries(state.keybindings),
isCustomized: state.isCustomized, isCustomized: state.isCustomized,
}), }),
migrate: (persisted, version) => migrate: (persisted, version) =>
runMigrations({ state: persisted, fromVersion: version }), runMigrations({ state: persisted, fromVersion: version }),
merge: (persisted, current) => {
if (!isPersistedState(persisted)) return current;
const entries = Object.entries(persisted.keybindings);
// Persistence boundary: keys are normalized by the migration chain.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const typedEntries = entries as Array<
[ShortcutKey, TActionWithOptionalArgs]
>;
return {
...current,
keybindings: new Map(typedEntries),
isCustomized: persisted.isCustomized,
};
},
}, },
), ),
); );
@@ -184,68 +216,59 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
if ( if (
modifierKey === "shift" && modifierKey === "shift" &&
isDOMElement(target) && isDOMElement(target) &&
isTypableDOMElement({ element: target as HTMLElement }) isTypableDOMElement({ element: target })
) { ) {
return null; return null;
} }
return `${modifierKey}+${key}` as ShortcutKey; return `${modifierKey}+${key}`;
} }
if ( if (isDOMElement(target) && isTypableDOMElement({ element: target })) {
isDOMElement(target) &&
isTypableDOMElement({ element: target as HTMLElement })
)
return null; return null;
}
return `${key}` as ShortcutKey; return key;
} }
function getPressedKey(ev: KeyboardEvent): string | null { function getPressedKey(ev: KeyboardEvent): Key | null {
const key = (ev.key ?? "").toLowerCase(); const raw = (ev.key ?? "").toLowerCase();
const code = ev.code ?? ""; const code = ev.code ?? "";
if (code === "Space" || key === " " || key === "spacebar" || key === "space") if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space")
return "space"; return "space";
if (key.startsWith("arrow")) return key.slice(5); if (raw === "arrowup") return "up";
if (raw === "arrowdown") return "down";
if (key === "escape") return "escape"; if (raw === "arrowleft") return "left";
if (key === "tab") return "tab"; if (raw === "arrowright") return "right";
if (key === "home") return "home";
if (key === "end") return "end";
if (key === "delete") return "delete";
if (key === "backspace") return "backspace";
if (code.startsWith("Key")) { if (code.startsWith("Key")) {
const letter = code.slice(3).toLowerCase(); const letter = code.slice(3).toLowerCase();
if (letter.length === 1 && letter >= "a" && letter <= "z") return letter; if (isKey(letter)) return letter;
} }
// Use physical key position for AZERTY and other non-QWERTY layouts // Use physical key position for AZERTY and other non-QWERTY layouts.
if (code.startsWith("Digit")) { if (code.startsWith("Digit")) {
const digit = code.slice(5); const digit = code.slice(5);
if (digit.length === 1 && digit >= "0" && digit <= "9") return digit; if (isKey(digit)) return digit;
} }
const isDigit = key.length === 1 && key >= "0" && key <= "9"; if (isKey(raw)) return raw;
if (isDigit) return key;
if (key === "/" || key === "." || key === "enter") return key;
return null; return null;
} }
function getActiveModifier(ev: KeyboardEvent): string | null { function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null {
const modifierKeys = { const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey;
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, const alt = ev.altKey;
alt: ev.altKey, const shift = ev.shiftKey;
shift: ev.shiftKey,
};
const activeModifier = Object.keys(modifierKeys) if (ctrl && alt && shift) return "ctrl+alt+shift";
.filter((key) => modifierKeys[key as keyof typeof modifierKeys]) if (ctrl && alt) return "ctrl+alt";
.join("+"); if (ctrl && shift) return "ctrl+shift";
if (alt && shift) return "alt+shift";
return activeModifier === "" ? null : activeModifier; if (ctrl) return "ctrl";
if (alt) return "alt";
if (shift) return "shift";
return null;
} }
@@ -0,0 +1,144 @@
import {
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";
import {
decodePersistedKeybindingsState,
migratePersistedKeybindingsState,
parseImportedKeybindings,
serializeKeybindingsState,
} from "../persistence";
describe("keybinding persistence", () => {
let warnSpy: ReturnType<typeof mock>;
let originalWarn: typeof console.warn;
beforeEach(() => {
originalWarn = console.warn;
warnSpy = mock(() => {});
console.warn = warnSpy;
});
afterEach(() => {
console.warn = originalWarn;
});
test("migrates legacy persisted keybindings before decoding them", () => {
const migrated = migratePersistedKeybindingsState({
state: {
keybindings: {
s: "split-selected",
"ctrl+v": "paste-selected",
},
isCustomized: true,
},
fromVersion: 2,
});
const decoded = decodePersistedKeybindingsState({ state: migrated });
expect(decoded).not.toBeNull();
if (!decoded) throw new Error("Expected migrated keybindings to decode");
expect(decoded.isCustomized).toBe(true);
expect(decoded.keybindings.get("s")).toBe("split");
expect(decoded.keybindings.get("ctrl+v")).toBe("paste-copied");
expect(decoded.keybindings.get("escape")).toBe("cancel-interaction");
expect(warnSpy).not.toHaveBeenCalled();
});
test("filters invalid persisted entries at the boundary and warns", () => {
const decoded = decodePersistedKeybindingsState({
state: {
keybindings: {
space: "toggle-play",
"shift+bogus": "toggle-play",
"ctrl+v": "not-an-action",
},
isCustomized: false,
},
});
expect(decoded).not.toBeNull();
if (!decoded) throw new Error("Expected persisted keybindings to decode");
expect(Array.from(decoded.keybindings.entries())).toEqual([
["space", "toggle-play"],
]);
expect(warnSpy).toHaveBeenCalledTimes(1);
});
test("returns null and warns when persisted shape is unrecognizable", () => {
const decoded = decodePersistedKeybindingsState({ state: "garbage" });
expect(decoded).toBeNull();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
test("round-trips actions that have no default shortcut", () => {
// `stop-playback` is a valid `TActionWithOptionalArgs` but is not in the
// defaults table; the validator must still accept it.
const serialized = serializeKeybindingsState({
keybindings: new Map([
["space", "toggle-play"],
["x", "stop-playback"],
["b", "toggle-bookmark"],
]),
isCustomized: true,
});
const decoded = decodePersistedKeybindingsState({ state: serialized });
expect(decoded).not.toBeNull();
if (!decoded) throw new Error("Expected round-tripped keybindings");
expect(decoded.keybindings.get("space")).toBe("toggle-play");
expect(decoded.keybindings.get("x")).toBe("stop-playback");
expect(decoded.keybindings.get("b")).toBe("toggle-bookmark");
expect(warnSpy).not.toHaveBeenCalled();
});
});
describe("parseImportedKeybindings", () => {
test("accepts a valid configuration", () => {
const result = parseImportedKeybindings({
config: {
space: "toggle-play",
x: "stop-playback",
},
});
expect(result.get("space")).toBe("toggle-play");
expect(result.get("x")).toBe("stop-playback");
});
test("throws on non-object input", () => {
expect(() => parseImportedKeybindings({ config: null })).toThrow(
/JSON object/,
);
expect(() => parseImportedKeybindings({ config: [] })).toThrow(
/JSON object/,
);
});
test("throws on non-string action values", () => {
expect(() =>
parseImportedKeybindings({ config: { space: 42 } }),
).toThrow(/expected string/);
});
test("throws on invalid shortcut keys", () => {
expect(() =>
parseImportedKeybindings({
config: { "shift+bogus": "toggle-play" },
}),
).toThrow(/shift\+bogus/);
});
test("throws on invalid actions", () => {
expect(() =>
parseImportedKeybindings({ config: { space: "not-an-action" } }),
).toThrow(/not-an-action/);
});
});
@@ -1,13 +1,8 @@
import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; import { getPersistedKeybindingsState } from "../persisted-state";
import type { TActionWithOptionalArgs } from "@/actions";
interface V2State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v2ToV3({ state }: { state: unknown }): unknown { export function v2ToV3({ state }: { state: unknown }): unknown {
const v2 = state as V2State; const v2 = getPersistedKeybindingsState({ state });
if (!v2) return state;
const renames: Record<string, string> = { const renames: Record<string, string> = {
"split-selected": "split", "split-selected": "split",
@@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown {
const migrated = { ...v2.keybindings }; const migrated = { ...v2.keybindings };
for (const [key, action] of Object.entries(migrated)) { for (const [key, action] of Object.entries(migrated)) {
if (action && renames[action]) { const renamedAction = action ? renames[action] : undefined;
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; if (renamedAction) {
migrated[key] = renamedAction;
} }
} }
@@ -1,14 +1,8 @@
import type { TActionWithOptionalArgs } from "@/actions"; import { getPersistedKeybindingsState } from "../persisted-state";
import type { ShortcutKey } from "@/actions/keybinding";
import type { KeybindingConfig } from "@/actions/keybinding";
interface V3State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v3ToV4({ state }: { state: unknown }): unknown { export function v3ToV4({ state }: { state: unknown }): unknown {
const v3 = state as V3State; const v3 = getPersistedKeybindingsState({ state });
if (!v3) return state;
const renames: Record<string, string> = { const renames: Record<string, string> = {
"paste-selected": "paste-copied", "paste-selected": "paste-copied",
@@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown {
const migrated = { ...v3.keybindings }; const migrated = { ...v3.keybindings };
for (const [key, action] of Object.entries(migrated)) { for (const [key, action] of Object.entries(migrated)) {
if (action && renames[action]) { const renamedAction = action ? renames[action] : undefined;
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; if (renamedAction) {
migrated[key] = renamedAction;
} }
} }
@@ -1,12 +1,8 @@
import type { KeybindingConfig } from "@/actions/keybinding"; import { getPersistedKeybindingsState } from "../persisted-state";
interface V4State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v4ToV5({ state }: { state: unknown }): unknown { export function v4ToV5({ state }: { state: unknown }): unknown {
const v4 = state as V4State; const v4 = getPersistedKeybindingsState({ state });
if (!v4) return state;
const keybindings = { ...v4.keybindings }; const keybindings = { ...v4.keybindings };
if (!keybindings.escape) { if (!keybindings.escape) {
@@ -1,12 +1,8 @@
import type { KeybindingConfig } from "@/actions/keybinding"; import { getPersistedKeybindingsState } from "../persisted-state";
interface V5State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v5ToV6({ state }: { state: unknown }): unknown { export function v5ToV6({ state }: { state: unknown }): unknown {
const v5 = state as V5State; const v5 = getPersistedKeybindingsState({ state });
if (!v5) return state;
const keybindings = { ...v5.keybindings }; const keybindings = { ...v5.keybindings };
if (keybindings.escape === "deselect-all") { if (keybindings.escape === "deselect-all") {
@@ -1,16 +1,12 @@
import type { KeybindingConfig } from "@/actions/keybinding"; import { getPersistedKeybindingsState } from "../persisted-state";
interface V6State {
keybindings: KeybindingConfig;
isCustomized: boolean;
}
export function v6ToV7({ state }: { state: unknown }): unknown { export function v6ToV7({ state }: { state: unknown }): unknown {
const v6 = state as V6State; const v6 = getPersistedKeybindingsState({ state });
if (!v6) return state;
const keybindings = { ...v6.keybindings }; const keybindings = { ...v6.keybindings };
for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) { for (const [key, action] of Object.entries(keybindings)) {
if (keybindings[key] === ("split-element" as never)) { if (action === "split-element") {
keybindings[key] = "split"; keybindings[key] = "split";
} }
} }
@@ -0,0 +1,37 @@
export type PersistedKeybindingConfig = Record<string, string | undefined>;
export interface PersistedKeybindingsState {
keybindings: PersistedKeybindingConfig;
isCustomized: boolean;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function getPersistedKeybindingsState({
state,
}: {
state: unknown;
}): PersistedKeybindingsState | null {
if (!isRecord(state)) return null;
const { keybindings, isCustomized } = state;
if (!isRecord(keybindings) || typeof isCustomized !== "boolean") {
return null;
}
const normalizedKeybindings: PersistedKeybindingConfig = {};
for (const [key, action] of Object.entries(keybindings)) {
if (action !== undefined && typeof action !== "string") {
return null;
}
normalizedKeybindings[key] = action;
}
return {
keybindings: normalizedKeybindings,
isCustomized,
};
}
@@ -0,0 +1,119 @@
import type { ShortcutKey } from "@/actions/keybinding";
import { isShortcutKey } from "@/actions/keybinding";
import type { TActionWithOptionalArgs } from "@/actions";
import { isActionWithOptionalArgs } from "@/actions";
import { runMigrations } from "./migrations";
import {
getPersistedKeybindingsState,
type PersistedKeybindingsState,
} from "./persisted-state";
export interface DecodedKeybindingsState {
keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
isCustomized: boolean;
}
export function serializeKeybindingsState({
keybindings,
isCustomized,
}: DecodedKeybindingsState): PersistedKeybindingsState {
return {
keybindings: Object.fromEntries(keybindings),
isCustomized,
};
}
export function migratePersistedKeybindingsState({
state,
fromVersion,
}: {
state: unknown;
fromVersion: number;
}): unknown {
return runMigrations({ state, fromVersion });
}
/**
* Decode a persisted/migrated keybindings blob into the in-memory shape.
*
* Lossy by design: invalid entries are dropped and a warning is emitted, so the
* user falls back to (mostly) sensible defaults instead of a broken store.
* Returns `null` if the top-level shape is unrecognizable, in which case the
* caller should keep its current state.
*/
export function decodePersistedKeybindingsState({
state,
}: {
state: unknown;
}): DecodedKeybindingsState | null {
const persisted = getPersistedKeybindingsState({ state });
if (!persisted) {
console.warn(
"[keybindings] Persisted state has unexpected shape; keeping current keybindings.",
state,
);
return null;
}
const keybindings = new Map<ShortcutKey, TActionWithOptionalArgs>();
const dropped: Array<{ key: string; action: string | undefined }> = [];
for (const [key, action] of Object.entries(persisted.keybindings)) {
if (action === undefined) continue;
if (!isShortcutKey(key) || !isActionWithOptionalArgs(action)) {
dropped.push({ key, action });
continue;
}
keybindings.set(key, action);
}
if (dropped.length > 0) {
console.warn(
"[keybindings] Dropped invalid persisted entries:",
dropped,
);
}
return {
keybindings,
isCustomized: persisted.isCustomized,
};
}
/**
* Parse a user-supplied keybindings configuration (typically the output of
* `JSON.parse` on an imported file).
*
* Strict by design: throws on the first invalid entry so the caller can surface
* the failure to the user instead of silently producing a half-applied import.
* Accepts `unknown` because the input has already crossed a trust boundary.
*/
export function parseImportedKeybindings({
config,
}: {
config: unknown;
}): Map<ShortcutKey, TActionWithOptionalArgs> {
if (typeof config !== "object" || config === null || Array.isArray(config)) {
throw new Error("Imported keybindings must be a JSON object");
}
const result = new Map<ShortcutKey, TActionWithOptionalArgs>();
for (const [key, action] of Object.entries(config)) {
if (action === undefined) continue;
if (typeof action !== "string") {
throw new Error(
`Invalid action for "${key}": expected string, got ${typeof action}`,
);
}
if (!isShortcutKey(key)) {
throw new Error(`Invalid shortcut key: ${JSON.stringify(key)}`);
}
if (!isActionWithOptionalArgs(action)) {
throw new Error(
`Invalid action for "${key}": ${JSON.stringify(action)}`,
);
}
result.set(key, action);
}
return result;
}
+3
View File
@@ -11,6 +11,7 @@ import type {
type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void; type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
const boundActions: Partial<Record<TAction, ActionHandler[]>> = {}; const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
export function bindAction<A extends TAction>( export function bindAction<A extends TAction>(
action: A, action: A,
handler: TActionFunc<A>, handler: TActionFunc<A>,
@@ -24,6 +25,7 @@ export function bindAction<A extends TAction>(
} }
} }
// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
export function unbindAction<A extends TAction>( export function unbindAction<A extends TAction>(
action: A, action: A,
handler: TActionFunc<A>, handler: TActionFunc<A>,
@@ -52,6 +54,7 @@ type InvokeActionFunc = {
): void; ): void;
}; };
// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger.
export const invokeAction: InvokeActionFunc = <A extends TAction>( export const invokeAction: InvokeActionFunc = <A extends TAction>(
action: A, action: A,
args?: TArgOfAction<A>, args?: TArgOfAction<A>,
+44 -44
View File
@@ -1,44 +1,44 @@
import type { MutableRefObject } from "react"; import type { MutableRefObject } from "react";
import type { TAction } from "./definitions"; import type { TAction } from "./definitions";
export type { TAction }; export type { TAction };
export type TActionArgsMap = { export type TActionArgsMap = {
"seek-forward": { seconds: number } | undefined; "seek-forward": { seconds: number } | undefined;
"seek-backward": { seconds: number } | undefined; "seek-backward": { seconds: number } | undefined;
"jump-forward": { seconds: number } | undefined; "jump-forward": { seconds: number } | undefined;
"jump-backward": { seconds: number } | undefined; "jump-backward": { seconds: number } | undefined;
"remove-media-asset": { projectId: string; assetId: string }; "remove-media-asset": { projectId: string; assetId: string };
"remove-media-assets": { projectId: string; assetIds: string[] }; "remove-media-assets": { projectId: string; assetIds: string[] };
}; };
type TKeysWithValueUndefined<T> = { type TKeysWithValueUndefined<T> = {
[K in keyof T]: undefined extends T[K] ? K : never; [K in keyof T]: undefined extends T[K] ? K : never;
}[keyof T]; }[keyof T];
export type TActionWithArgs = keyof TActionArgsMap; export type TActionWithArgs = keyof TActionArgsMap;
export type TActionWithOptionalArgs = export type TActionWithOptionalArgs =
| TActionWithNoArgs | TActionWithNoArgs
| TKeysWithValueUndefined<TActionArgsMap>; | TKeysWithValueUndefined<TActionArgsMap>;
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>; export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
export type TArgOfAction<A extends TAction> = A extends TActionWithArgs export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
? TActionArgsMap[A] ? TActionArgsMap[A]
: undefined; : undefined;
export type TActionFunc<A extends TAction> = A extends TActionWithArgs export type TActionFunc<A extends TAction> = A extends TActionWithArgs
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
: (_?: undefined, trigger?: TInvocationTrigger) => void; : (_?: undefined, trigger?: TInvocationTrigger) => void;
export type TInvocationTrigger = "keypress" | "mouseclick"; export type TInvocationTrigger = "keypress" | "mouseclick";
export type TBoundActionList = { export type TBoundActionList = {
[A in TAction]?: Array<TActionFunc<A>>; [A in TAction]?: Array<TActionFunc<A>>;
}; };
export type TActionHandlerOptions = export type TActionHandlerOptions =
| MutableRefObject<boolean> | MutableRefObject<boolean>
| boolean | boolean
| undefined; | undefined;
@@ -8,6 +8,7 @@ import type {
} from "@/actions"; } from "@/actions";
import { bindAction, unbindAction } from "@/actions"; import { bindAction, unbindAction } from "@/actions";
// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive).
export function useActionHandler<A extends TAction>( export function useActionHandler<A extends TAction>(
action: A, action: A,
handler: TActionFunc<A>, handler: TActionFunc<A>,
+86 -54
View File
@@ -1,11 +1,20 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useState } from "react";
import { useTimelineStore } from "@/timeline/timeline-store"; import { useTimelineStore } from "@/timeline/timeline-store";
import { useActionHandler } from "@/actions/use-action-handler"; import { useActionHandler } from "@/actions/use-action-handler";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; 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 { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
import { getElementsAtTime, hasMediaId } from "@/timeline"; import { getElementsAtTime, hasMediaId } from "@/timeline";
import { cancelInteraction } from "@/editor/cancel-interaction"; import { cancelInteraction } from "@/editor/cancel-interaction";
@@ -16,6 +25,7 @@ import {
clearActiveScope, clearActiveScope,
type ScopeEntry, type ScopeEntry,
} from "@/selection/scope"; } from "@/selection/scope";
import { useCommittedRef } from "@/hooks/use-committed-ref";
export function useEditorActions() { export function useEditorActions() {
const editor = useEditor(); const editor = useEditor();
@@ -27,47 +37,34 @@ export function useEditorActions() {
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
const hasTimelineSelectionRef = useRef(false);
const clearTimelineSelectionRef = useRef(() => {});
const clearTimelineActiveSelectionRef = useRef(() => {});
const timelineScopeRef = useRef<ScopeEntry | null>(null);
const hasTimelineSelection = const hasTimelineSelection =
selectedElements.length > 0 || selectedElements.length > 0 ||
selectedKeyframes.length > 0 || selectedKeyframes.length > 0 ||
selectedMaskPointSelection !== null; selectedMaskPointSelection !== null;
const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection);
hasTimelineSelectionRef.current = hasTimelineSelection; const clearTimelineSelectionRef = useCommittedRef(() => {
clearTimelineSelectionRef.current = () => {
editor.selection.clearSelection(); editor.selection.clearSelection();
}; });
clearTimelineActiveSelectionRef.current = () => { const clearTimelineActiveSelectionRef = useCommittedRef(() => {
editor.selection.clearMostSpecificSelection(); editor.selection.clearMostSpecificSelection();
}; });
const [timelineScope] = useState<ScopeEntry>(() => ({
if (!timelineScopeRef.current) { hasSelection: () => hasTimelineSelectionRef.current,
timelineScopeRef.current = { clear: () => {
hasSelection: () => hasTimelineSelectionRef.current, clearTimelineSelectionRef.current();
clear: () => { },
clearTimelineSelectionRef.current(); clearActive: () => {
}, clearTimelineActiveSelectionRef.current();
clearActive: () => { },
clearTimelineActiveSelectionRef.current(); }));
},
};
}
useEffect(() => { useEffect(() => {
if (!hasTimelineSelection) { if (!hasTimelineSelection) {
return; return;
} }
const timelineScope = timelineScopeRef.current;
if (!timelineScope) {
return;
}
return activateScope({ entry: timelineScope }); return activateScope({ entry: timelineScope });
}, [hasTimelineSelection]); }, [hasTimelineSelection, timelineScope]);
useActionHandler( useActionHandler(
"toggle-play", "toggle-play",
@@ -83,7 +80,7 @@ export function useEditorActions() {
if (editor.playback.getIsPlaying()) { if (editor.playback.getIsPlaying()) {
editor.playback.toggle(); editor.playback.toggle();
} }
editor.playback.seek({ time: 0 }); editor.playback.seek({ time: ZERO_MEDIA_TIME });
}, },
undefined, undefined,
); );
@@ -92,11 +89,15 @@ export function useEditorActions() {
"seek-forward", "seek-forward",
(args) => { (args) => {
const seconds = args?.seconds ?? 1; const seconds = args?.seconds ?? 1;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({ editor.playback.seek({
time: Math.min( time: minMediaTime({
editor.timeline.getTotalDuration(), a: editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + seconds, b: addMediaTime({
), a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
}); });
}, },
undefined, undefined,
@@ -106,8 +107,15 @@ export function useEditorActions() {
"seek-backward", "seek-backward",
(args) => { (args) => {
const seconds = args?.seconds ?? 1; const seconds = args?.seconds ?? 1;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({ 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, undefined,
@@ -117,15 +125,20 @@ export function useEditorActions() {
"frame-step-forward", "frame-step-forward",
() => { () => {
const fps = editor.project.getActive().settings.fps; const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round( const ticksPerFrame = mediaTime({
(TICKS_PER_SECOND * fps.denominator) / fps.numerator, ticks: Math.round(
); (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + ticksPerFrame,
), ),
}); });
editor.playback.seek({
time: minMediaTime({
a: editor.timeline.getTotalDuration(),
b: addMediaTime({
a: editor.playback.getCurrentTime(),
b: ticksPerFrame,
}),
}),
});
}, },
undefined, undefined,
); );
@@ -134,11 +147,19 @@ export function useEditorActions() {
"frame-step-backward", "frame-step-backward",
() => { () => {
const fps = editor.project.getActive().settings.fps; const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round( const ticksPerFrame = mediaTime({
(TICKS_PER_SECOND * fps.denominator) / fps.numerator, ticks: Math.round(
); (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
),
});
editor.playback.seek({ 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, undefined,
@@ -148,11 +169,15 @@ export function useEditorActions() {
"jump-forward", "jump-forward",
(args) => { (args) => {
const seconds = args?.seconds ?? 5; const seconds = args?.seconds ?? 5;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({ editor.playback.seek({
time: Math.min( time: minMediaTime({
editor.timeline.getTotalDuration(), a: editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + seconds, b: addMediaTime({
), a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
}); });
}, },
undefined, undefined,
@@ -162,8 +187,15 @@ export function useEditorActions() {
"jump-backward", "jump-backward",
(args) => { (args) => {
const seconds = args?.seconds ?? 5; const seconds = args?.seconds ?? 5;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({ 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, undefined,
@@ -172,7 +204,7 @@ export function useEditorActions() {
useActionHandler( useActionHandler(
"goto-start", "goto-start",
() => { () => {
editor.playback.seek({ time: 0 }); editor.playback.seek({ time: ZERO_MEDIA_TIME });
}, },
undefined, undefined,
); );
@@ -273,7 +305,7 @@ export function useEditorActions() {
if (!selectedMaskPointSelection) { if (!selectedMaskPointSelection) {
return; return;
} }
editor.timeline.deleteCustomMaskPoints({ editor.timeline.deleteFreeformPathMaskPoints({
trackId: selectedMaskPointSelection.trackId, trackId: selectedMaskPointSelection.trackId,
elementId: selectedMaskPointSelection.elementId, elementId: selectedMaskPointSelection.elementId,
maskId: selectedMaskPointSelection.maskId, maskId: selectedMaskPointSelection.maskId,
+1 -1
View File
@@ -33,7 +33,7 @@ export function useKeybindingsListener() {
const isTextInput = const isTextInput =
activeElement instanceof HTMLElement && activeElement instanceof HTMLElement &&
isTypableDOMElement({ element: activeElement }); isTypableDOMElement({ element: activeElement });
const boundAction = binding ? keybindings[binding] : undefined; const boundAction = binding ? keybindings.get(binding) : undefined;
if (normalizedKey === "escape" && isTextInput) { if (normalizedKey === "escape" && isTextInput) {
activeElement.blur(); activeElement.blur();
@@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() {
const { keybindings } = useKeybindingsStore(); const { keybindings } = useKeybindingsStore();
const shortcuts = useMemo(() => { const shortcuts = useMemo(() => {
const result: KeyboardShortcut[] = []; const actionToKeys = new Map<TActionWithOptionalArgs, string[]>();
const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {};
for (const [key, action] of Object.entries(keybindings) as Array< for (const [key, action] of keybindings) {
[string, TActionWithOptionalArgs | undefined] const existing = actionToKeys.get(action);
>) { if (existing) {
if (action) { existing.push(formatKey({ key }));
if (!actionToKeys[action]) { } else {
actionToKeys[action] = []; actionToKeys.set(action, [formatKey({ key })]);
}
actionToKeys[action].push(formatKey({ key }));
} }
} }
for (const [action, keys] of Object.entries(actionToKeys) as Array< const result: KeyboardShortcut[] = [];
[TActionWithOptionalArgs, string[]] for (const [action, keys] of actionToKeys) {
>) {
const actionDef = ACTIONS[action]; const actionDef = ACTIONS[action];
if (!actionDef) { if (!actionDef) continue;
continue;
}
result.push({ result.push({
id: action, id: action,
keys, keys,
@@ -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();
});
});
@@ -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");
});
});
-344
View File
@@ -1,344 +0,0 @@
import { converter, formatHex, formatHex8, parse } from "culori";
import type {
AnimationBindingComponent,
AnimationBindingOfKind,
AnimationBindingInstance,
AnimationBindingKind,
ColorAnimationBinding,
DiscreteAnimationBinding,
NumberAnimationBinding,
AnimationPath,
AnimationValue,
DiscreteValue,
Vector2AnimationBinding,
VectorValue,
} from "@/animation/types";
import { clamp } from "@/utils/math";
interface LinearRgba {
r: number;
g: number;
b: number;
a: number;
}
export type AnimationComponentValue = number | DiscreteValue;
const toRgb = converter("rgb");
function srgbToLinear({ value }: { value: number }): number {
return value <= 0.04045
? value / 12.92
: Math.pow((value + 0.055) / 1.055, 2.4);
}
function linearToSrgb({ value }: { value: number }): number {
const clamped = clamp({ value, min: 0, max: 1 });
return clamped <= 0.0031308
? clamped * 12.92
: 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isVectorValue(value: unknown): value is VectorValue {
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
}
export type EasingMode = "independent" | "shared";
/**
* Declares how easing curves apply to a binding's components.
* "shared" means all components always use the same curve (e.g. color — you never
* want to ease R independently from G/B/A). "independent" means each component
* can have its own curve.
*/
export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
return kind === "color" ? "shared" : "independent";
}
export function getBindingComponentKeys({
kind,
}: {
kind: AnimationBindingKind;
}): string[] {
if (kind === "vector2") {
return ["x", "y"];
}
if (kind === "color") {
return ["r", "g", "b", "a"];
}
return ["value"];
}
export function buildBindingChannelId({
path,
componentKey,
}: {
path: AnimationPath;
componentKey: string;
}): string {
return `${path}:${componentKey}`;
}
function createBindingComponent<TKey extends string>({
path,
key,
}: {
path: AnimationPath;
key: TKey;
}): AnimationBindingComponent<TKey> {
return {
key,
channelId: buildBindingChannelId({ path, componentKey: key }),
};
}
function cloneBindingComponents<TKey extends string>({
components,
}: {
components: AnimationBindingComponent<TKey>[];
}): AnimationBindingComponent<TKey>[] {
return components.map((component) => ({ ...component }));
}
const animationBindingFactories = {
color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
path,
kind: "color",
colorSpace: "srgb-linear",
components: [
createBindingComponent({ path, key: "r" }),
createBindingComponent({ path, key: "g" }),
createBindingComponent({ path, key: "b" }),
createBindingComponent({ path, key: "a" }),
],
}),
vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
path,
kind: "vector2",
components: [
createBindingComponent({ path, key: "x" }),
createBindingComponent({ path, key: "y" }),
],
}),
number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
path,
kind: "number",
components: [createBindingComponent({ path, key: "value" })],
}),
discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
path,
kind: "discrete",
components: [createBindingComponent({ path, key: "value" })],
}),
} satisfies {
[K in AnimationBindingKind]: ({
path,
}: {
path: AnimationPath;
}) => AnimationBindingOfKind<K>;
};
export function createAnimationBinding<TKind extends AnimationBindingKind>({
path,
kind,
}: {
path: AnimationPath;
kind: TKind;
}): AnimationBindingOfKind<TKind>;
export function createAnimationBinding({
path,
kind,
}: {
path: AnimationPath;
kind: AnimationBindingKind;
}): AnimationBindingInstance {
return animationBindingFactories[kind]({ path });
}
const animationBindingCloners = {
color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
vector2: ({
binding,
}: {
binding: Vector2AnimationBinding;
}): Vector2AnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
number: ({
binding,
}: {
binding: NumberAnimationBinding;
}): NumberAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
discrete: ({
binding,
}: {
binding: DiscreteAnimationBinding;
}): DiscreteAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
} satisfies {
[K in AnimationBindingKind]: ({
binding,
}: {
binding: AnimationBindingOfKind<K>;
}) => AnimationBindingOfKind<K>;
};
export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
binding,
}: {
binding: AnimationBindingOfKind<TKind>;
}): AnimationBindingOfKind<TKind>;
export function cloneAnimationBinding({
binding,
}: {
binding: AnimationBindingInstance;
}): AnimationBindingInstance {
switch (binding.kind) {
case "color":
return animationBindingCloners.color({ binding });
case "vector2":
return animationBindingCloners.vector2({ binding });
case "number":
return animationBindingCloners.number({ binding });
case "discrete":
return animationBindingCloners.discrete({ binding });
}
}
export function parseColorToLinearRgba({
color,
}: {
color: string;
}): LinearRgba | null {
const parsed = parse(color);
const rgb = parsed ? toRgb(parsed) : null;
if (!rgb) {
return null;
}
return {
r: srgbToLinear({ value: rgb.r ?? 0 }),
g: srgbToLinear({ value: rgb.g ?? 0 }),
b: srgbToLinear({ value: rgb.b ?? 0 }),
a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }),
};
}
export function formatLinearRgba({
color,
}: {
color: LinearRgba;
}): string {
const rgb = {
mode: "rgb",
r: linearToSrgb({ value: color.r }),
g: linearToSrgb({ value: color.g }),
b: linearToSrgb({ value: color.b }),
alpha: clamp({ value: color.a, min: 0, max: 1 }),
} as const;
return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
}
export function decomposeAnimationValue({
kind,
value,
}: {
kind: AnimationBindingKind;
value: AnimationValue;
}): Record<string, AnimationComponentValue> | null {
if (kind === "number") {
return typeof value === "number" ? { value } : null;
}
if (kind === "vector2") {
return isVectorValue(value) ? { x: value.x, y: value.y } : null;
}
if (kind === "color") {
if (typeof value !== "string") {
return null;
}
const parsed = parseColorToLinearRgba({ color: value });
if (!parsed) {
return null;
}
return {
r: parsed.r,
g: parsed.g,
b: parsed.b,
a: parsed.a,
};
}
return typeof value === "string" || typeof value === "boolean"
? { value }
: null;
}
export function composeAnimationValue({
binding,
componentValues,
}: {
binding: AnimationBindingInstance;
componentValues: Record<string, AnimationComponentValue | undefined>;
}): AnimationValue | null {
if (binding.kind === "number") {
const value = componentValues.value;
return typeof value === "number" ? value : null;
}
if (binding.kind === "vector2") {
const x = componentValues.x;
const y = componentValues.y;
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
}
if (binding.kind === "color") {
const r = componentValues.r;
const g = componentValues.g;
const b = componentValues.b;
const a = componentValues.a;
if (
typeof r !== "number" ||
typeof g !== "number" ||
typeof b !== "number" ||
typeof a !== "number"
) {
return null;
}
return formatLinearRgba({
color: {
r,
g,
b,
a,
},
});
}
const value = componentValues.value;
return typeof value === "string" || typeof value === "boolean" ? value : null;
}
+57
View File
@@ -0,0 +1,57 @@
import type {
AnimationChannel,
ChannelData,
CompositeChannelData,
} from "@/animation/types";
const LEGACY_ANIMATION_STORAGE_KEYS = new Set(["bindings", "channels"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isLeafChannelData(
data: ChannelData | undefined,
): data is AnimationChannel {
return isRecord(data) && Array.isArray(data.keys);
}
export function isCompositeChannelData(
data: ChannelData | undefined,
): data is CompositeChannelData {
return isRecord(data) && !Array.isArray(data.keys);
}
export function getChannelsFromData({
data,
}: {
data: ChannelData | undefined;
}): AnimationChannel[] {
if (isLeafChannelData(data)) {
return [data];
}
if (!isCompositeChannelData(data)) {
return [];
}
return Object.values(data).filter(isLeafChannelData);
}
export function getChannelEntriesFromData({
data,
}: {
data: ChannelData | undefined;
}): Array<[string, AnimationChannel]> {
if (isLeafChannelData(data)) {
return [["value", data]];
}
if (!isCompositeChannelData(data)) {
return [];
}
return Object.entries(data).flatMap(([componentKey, channel]) =>
isLeafChannelData(channel) ? [[componentKey, channel]] : [],
);
}
export function isAnimationStorageKey({ key }: { key: string }): boolean {
return !LEGACY_ANIMATION_STORAGE_KEYS.has(key);
}
+3 -2
View File
@@ -7,6 +7,7 @@ import type {
NormalizedCubicBezier, NormalizedCubicBezier,
ScalarAnimationKey, ScalarAnimationKey,
} from "@/animation/types"; } from "@/animation/types";
import { roundMediaTime } from "@/wasm";
const VALUE_EPSILON = 1e-6; const VALUE_EPSILON = 1e-6;
@@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
return { return {
rightHandle: { rightHandle: {
dt: spanTime * x1, dt: roundMediaTime({ time: spanTime * x1 }),
dv: effectiveSpanValue * y1, dv: effectiveSpanValue * y1,
}, },
leftHandle: { leftHandle: {
dt: spanTime * (x2 - 1), dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
dv: effectiveSpanValue * (y2 - 1), dv: effectiveSpanValue * (y2 - 1),
}, },
}; };
+10 -8
View File
@@ -1,9 +1,8 @@
import type { ParamValues } from "@/params";
import type { Effect } from "@/effects/types";
import type { import type {
ElementAnimations, ElementAnimations,
EffectParamPath, EffectParamPath,
} from "@/animation/types"; } from "@/animation/types";
import type { ParamValues } from "@/params";
import { removeElementKeyframe } from "./keyframes"; import { removeElementKeyframe } from "./keyframes";
import { resolveAnimationPathValueAtTime } from "./resolve"; import { resolveAnimationPathValueAtTime } from "./resolve";
@@ -56,23 +55,26 @@ export function parseEffectParamPath({
} }
export function resolveEffectParamsAtTime({ export function resolveEffectParamsAtTime({
effect, effectId,
params,
animations, animations,
localTime, localTime,
}: { }: {
effect: Effect; effectId: string;
params: ParamValues;
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
localTime: number; localTime: number;
}): ParamValues { }): ParamValues {
const safeLocalTime = Math.max(0, localTime);
const resolved: ParamValues = {}; const resolved: ParamValues = {};
for (const [paramKey, staticValue] of Object.entries(effect.params)) { for (const [paramKey, staticValue] of Object.entries(params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey }); const path = buildEffectParamPath({ effectId, paramKey });
resolved[paramKey] = animations?.bindings[path] resolved[paramKey] = animations?.[path]
? resolveAnimationPathValueAtTime({ ? resolveAnimationPathValueAtTime({
animations, animations,
propertyPath: path, propertyPath: path,
localTime, localTime: safeLocalTime,
fallbackValue: staticValue, fallbackValue: staticValue,
}) })
: staticValue; : staticValue;
+26 -11
View File
@@ -1,21 +1,35 @@
import type { import type {
AnimationBindingInstance,
AnimationPath, AnimationPath,
ElementAnimations, ElementAnimations,
ChannelData,
ScalarAnimationChannel, ScalarAnimationChannel,
ScalarGraphChannel, ScalarGraphChannel,
ScalarGraphKeyframeContext, ScalarGraphKeyframeContext,
} from "@/animation/types"; } from "@/animation/types";
import type { ChannelEasingMode } from "@/params";
import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
import { isScalarChannel } from "./interpolation";
export interface EditableScalarChannels { export interface EditableScalarChannels {
binding: AnimationBindingInstance; easingMode: ChannelEasingMode;
channels: ScalarGraphChannel[]; channels: ScalarGraphChannel[];
} }
function isScalarAnimationChannel( function isScalarAnimationChannel(
channel: ElementAnimations["channels"][string], channel: ChannelData | undefined,
): channel is ScalarAnimationChannel { ): 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({ export function getEditableScalarChannels({
@@ -25,13 +39,15 @@ export function getEditableScalarChannels({
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPath; propertyPath: AnimationPath;
}): EditableScalarChannels | null { }): EditableScalarChannels | null {
const binding = animations?.bindings[propertyPath]; const data = animations?.[propertyPath];
if (!binding) { if (!data) {
return null; return null;
} }
const channels = binding.components.flatMap((component) => { const channelEntries = isLeafChannelData(data)
const channel = animations?.channels[component.channelId]; ? [["value", data] as const]
: Object.entries(data);
const channels = channelEntries.flatMap(([componentKey, channel]) => {
if (!isScalarAnimationChannel(channel)) { if (!isScalarAnimationChannel(channel)) {
return []; return [];
} }
@@ -39,14 +55,13 @@ export function getEditableScalarChannels({
return [ return [
{ {
propertyPath, propertyPath,
componentKey: component.key, componentKey,
channelId: component.channelId,
channel, channel,
} satisfies ScalarGraphChannel, } satisfies ScalarGraphChannel,
]; ];
}); });
return { binding, channels }; return { easingMode: getEasingModeForChannelData({ data }), channels };
} }
export function getEditableScalarChannel({ export function getEditableScalarChannel({
+12 -20
View File
@@ -2,11 +2,7 @@ import type {
ElementAnimations, ElementAnimations,
GraphicParamPath, GraphicParamPath,
} from "@/animation/types"; } from "@/animation/types";
import type { ParamValues } from "@/params"; import type { ParamDefinition, ParamValues } from "@/params";
import {
getGraphicDefinition,
resolveGraphicParams,
} from "@/graphics";
import { resolveAnimationPathValueAtTime } from "./resolve"; import { resolveAnimationPathValueAtTime } from "./resolve";
export const GRAPHIC_PARAM_PATH_PREFIX = "params."; export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
@@ -39,33 +35,29 @@ export function parseGraphicParamPath({
} }
export function resolveGraphicParamsAtTime({ export function resolveGraphicParamsAtTime({
element, params,
definitions,
animations,
localTime, localTime,
}: { }: {
element: { params: ParamValues;
definitionId: string; definitions: ParamDefinition[];
params: ParamValues; animations?: ElementAnimations;
animations?: ElementAnimations;
};
localTime: number; localTime: number;
}): ParamValues { }): ParamValues {
const definition = getGraphicDefinition({ const resolved: ParamValues = { ...params };
definitionId: element.definitionId,
});
const baseParams = resolveGraphicParams(definition, element.params);
const resolved: ParamValues = { ...baseParams };
for (const param of definition.params) { for (const param of definitions) {
const path = buildGraphicParamPath({ paramKey: param.key }); const path = buildGraphicParamPath({ paramKey: param.key });
if (!element.animations?.bindings[path]) { if (!animations?.[path]) {
continue; continue;
} }
resolved[param.key] = resolveAnimationPathValueAtTime({ resolved[param.key] = resolveAnimationPathValueAtTime({
animations: element.animations, animations,
propertyPath: path, propertyPath: path,
localTime: Math.max(0, localTime), localTime: Math.max(0, localTime),
fallbackValue: baseParams[param.key] ?? param.default, fallbackValue: params[param.key] ?? param.default,
}); });
} }
+5 -29
View File
@@ -16,31 +16,14 @@ export {
setChannel, setChannel,
splitAnimationsAtTime, splitAnimationsAtTime,
updateScalarKeyframeCurve, updateScalarKeyframeCurve,
upsertElementKeyframe,
upsertPathKeyframe, upsertPathKeyframe,
} from "./keyframes"; } from "./keyframes";
export { export {
getElementLocalTime, getElementLocalTime,
resolveAnimationPathValueAtTime, resolveAnimationPathValueAtTime,
resolveColorAtTime,
resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "./resolve"; } from "./resolve";
export {
coerceAnimationValueForProperty,
getAnimationPropertyDefinition,
getDefaultInterpolationForProperty,
getElementBaseValueForProperty,
isAnimationPropertyPath,
supportsAnimationProperty,
type AnimationPropertyDefinition,
type NumericSpec,
withElementBaseValueForProperty,
} from "./property-registry";
export { export {
getElementKeyframes, getElementKeyframes,
getKeyframeById, getKeyframeById,
@@ -75,15 +58,6 @@ export {
resolveEffectParamsAtTime, resolveEffectParamsAtTime,
} from "./effect-param-channel"; } from "./effect-param-channel";
export {
isAnimationPath,
coerceAnimationValueForParam,
resolveAnimationTarget,
getParamValueKind,
getParamDefaultInterpolation,
type AnimationPathDescriptor,
} from "./target-resolver";
export { export {
getGroupKeyframesAtTime, getGroupKeyframesAtTime,
hasGroupKeyframeAtTime, hasGroupKeyframeAtTime,
@@ -91,6 +65,8 @@ export {
} from "./property-groups"; } from "./property-groups";
export { export {
type EasingMode, isAnimationPath,
getEasingModeForKind, isAnimationPropertyPath,
} from "./binding-values"; } from "./path";
export type { NumericSpec } from "./types";
+80 -30
View File
@@ -1,20 +1,22 @@
import type { import type {
AnimationChannel, AnimationChannel,
AnimationInterpolation, AnimationInterpolation,
AnimationValue, Channel,
DiscreteAnimationChannel, DiscreteAnimationChannel,
DiscreteValue, DiscreteValue,
ScalarAnimationChannel, ScalarAnimationChannel,
ScalarAnimationKey, ScalarAnimationKey,
ScalarSegmentType, ScalarSegmentType,
} from "@/animation/types"; } from "@/animation/types";
import { clamp } from "@/utils/math"; import type { ParamValue } from "@/params";
import { mediaTime } from "@/wasm";
import { import {
getBezierPoint, getBezierPoint,
getDefaultLeftHandle, getDefaultLeftHandle,
getDefaultRightHandle, getDefaultRightHandle,
solveBezierProgressForTime, solveBezierProgressForTime,
} from "./bezier"; } from "./bezier";
import { clamp } from "@/utils/math";
function byTimeAscending({ function byTimeAscending({
leftTime, leftTime,
@@ -63,9 +65,13 @@ function normalizeRightHandle({
return undefined; return undefined;
} }
const span = Math.max(1, rightKey.time - leftKey.time); const span = mediaTime({
ticks: Math.max(1, rightKey.time - leftKey.time),
});
return { return {
dt: Math.min(span, Math.max(0, handle.dt)), dt: mediaTime({
ticks: Math.min(span, Math.max(0, handle.dt)),
}),
dv: handle.dv, dv: handle.dv,
}; };
} }
@@ -83,9 +89,13 @@ function normalizeLeftHandle({
return undefined; return undefined;
} }
const span = Math.max(1, rightKey.time - leftKey.time); const span = mediaTime({
ticks: Math.max(1, rightKey.time - leftKey.time),
});
return { return {
dt: Math.max(-span, Math.min(0, handle.dt)), dt: mediaTime({
ticks: Math.max(-span, Math.min(0, handle.dt)),
}),
dv: handle.dv, dv: handle.dv,
}; };
} }
@@ -102,7 +112,7 @@ function normalizeScalarKey({
}; };
} }
function normalizeScalarChannel({ export function normalizeScalarChannel({
channel, channel,
}: { }: {
channel: ScalarAnimationChannel; channel: ScalarAnimationChannel;
@@ -145,17 +155,11 @@ function normalizeScalarChannel({
}; };
} }
export function normalizeChannel<TChannel extends AnimationChannel>({ export function normalizeDiscreteChannel({
channel, channel,
}: { }: {
channel: TChannel; channel: DiscreteAnimationChannel;
}): TChannel { }): DiscreteAnimationChannel {
if (channel.kind === "scalar") {
return normalizeScalarChannel({
channel,
}) as TChannel;
}
return { return {
...channel, ...channel,
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
@@ -164,7 +168,39 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
rightTime: rightKeyframe.time, 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({ function extrapolateScalarEdge({
@@ -207,7 +243,7 @@ export function getScalarChannelValueAtTime({
time, time,
fallbackValue, fallbackValue,
}: { }: {
channel: ScalarAnimationChannel | undefined; channel: Channel<number> | undefined;
time: number; time: number;
fallbackValue: number; fallbackValue: number;
}): number { }): number {
@@ -215,9 +251,7 @@ export function getScalarChannelValueAtTime({
return fallbackValue; return fallbackValue;
} }
const normalizedChannel = normalizeChannel({ const normalizedChannel = normalizeScalarChannel({ channel });
channel,
});
const firstKey = normalizedChannel.keys[0]; const firstKey = normalizedChannel.keys[0];
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
if (!firstKey || !lastKey) { if (!firstKey || !lastKey) {
@@ -319,7 +353,7 @@ export function getDiscreteChannelValueAtTime({
time, time,
fallbackValue, fallbackValue,
}: { }: {
channel: DiscreteAnimationChannel | undefined; channel: Channel<DiscreteValue> | undefined;
time: number; time: number;
fallbackValue: DiscreteValue; fallbackValue: DiscreteValue;
}): DiscreteValue { }): DiscreteValue {
@@ -327,9 +361,7 @@ export function getDiscreteChannelValueAtTime({
return fallbackValue; return fallbackValue;
} }
const normalizedChannel = normalizeChannel({ const normalizedChannel = normalizeDiscreteChannel({ channel });
channel,
});
let currentValue = fallbackValue; let currentValue = fallbackValue;
for (const key of normalizedChannel.keys) { for (const key of normalizedChannel.keys) {
if (time < key.time) { if (time < key.time) {
@@ -340,6 +372,24 @@ export function getDiscreteChannelValueAtTime({
return currentValue; return currentValue;
} }
export function getChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: Channel<number> | undefined;
time: number;
fallbackValue: number;
}): number;
export function getChannelValueAtTime<TValue extends DiscreteValue>({
channel,
time,
fallbackValue,
}: {
channel: DiscreteAnimationChannel | undefined;
time: number;
fallbackValue: TValue;
}): TValue;
export function getChannelValueAtTime({ export function getChannelValueAtTime({
channel, channel,
time, time,
@@ -347,14 +397,14 @@ export function getChannelValueAtTime({
}: { }: {
channel: AnimationChannel | undefined; channel: AnimationChannel | undefined;
time: number; time: number;
fallbackValue: AnimationValue; fallbackValue: ParamValue;
}): AnimationValue { }): ParamValue {
if (!channel || channel.keys.length === 0) { if (!channel || channel.keys.length === 0) {
return fallbackValue; return fallbackValue;
} }
if (channel.kind === "scalar") { if (typeof fallbackValue === "number") {
return typeof fallbackValue === "number" return isScalarChannel(channel)
? getScalarChannelValueAtTime({ ? getScalarChannelValueAtTime({
channel, channel,
time, time,
@@ -368,7 +418,7 @@ export function getChannelValueAtTime({
} }
return getDiscreteChannelValueAtTime({ return getDiscreteChannelValueAtTime({
channel, channel: isScalarChannel(channel) ? undefined : channel,
time, time,
fallbackValue, fallbackValue,
}); });
+124 -104
View File
@@ -1,75 +1,75 @@
import type { import type {
AnimationBindingInstance,
AnimationChannel, AnimationChannel,
AnimationPath, AnimationPath,
ChannelData,
ElementAnimations, ElementAnimations,
ElementKeyframe, ElementKeyframe,
} from "@/animation/types"; } from "@/animation/types";
import { formatLinearRgba } from "@/params";
import { import {
type AnimationComponentValue, getChannelEntriesFromData,
composeAnimationValue, isAnimationStorageKey,
} from "./binding-values"; } from "./channel-data";
import { import {
getChannelValueAtTime, getChannelValueAtTime,
getScalarSegmentInterpolation, getScalarSegmentInterpolation,
isScalarChannel,
} from "./interpolation"; } from "./interpolation";
import { isAnimationPath } from "./target-resolver"; import { isAnimationPath } from "./path";
function getBindingFallbackValue({ function getChannelFallbackValue({
channel, channel,
}: { }: {
channel: ElementAnimations["channels"][string]; channel: AnimationChannel;
}) { }) {
if (!channel || channel.keys.length === 0) { if (channel.keys.length === 0) {
return channel?.kind === "discrete" ? false : 0; return isScalarChannel(channel) ? 0 : false;
} }
return channel.keys[0].value; return channel.keys[0].value;
} }
interface BindingKeyframeMatch { interface ChannelKeyframeMatch {
componentKey: string;
componentIndex: number; componentIndex: number;
channel: AnimationChannel; channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number]; keyframe: AnimationChannel["keys"][number];
} }
function getBindingKeyframeMatches({ function getChannelKeyframeMatches({
animations, data,
binding,
}: { }: {
animations: ElementAnimations; data: ChannelData | undefined;
binding: AnimationBindingInstance; }): ChannelKeyframeMatch[] {
}): BindingKeyframeMatch[] { return getChannelEntriesFromData({ data }).flatMap(
return binding.components.flatMap((component, componentIndex) => { ([componentKey, channel], componentIndex) => {
const channel = animations.channels[component.channelId]; if (channel.keys.length === 0) {
if (!channel || channel.keys.length === 0) { return [];
return []; }
}
return channel.keys.map((keyframe) => ({ return channel.keys.map((keyframe) => ({
componentIndex, componentKey,
channel, componentIndex,
keyframe, channel,
})); keyframe,
}); }));
},
);
} }
function getUniqueBindingKeyframeMatches({ function getUniqueChannelKeyframeMatches({
animations, data,
binding,
}: { }: {
animations: ElementAnimations; data: ChannelData | undefined;
binding: AnimationBindingInstance; }): ChannelKeyframeMatch[] {
}): BindingKeyframeMatch[] { const sortedMatches = getChannelKeyframeMatches({
const sortedMatches = getBindingKeyframeMatches({ data,
animations,
binding,
}).sort( }).sort(
(leftMatch, rightMatch) => (leftMatch, rightMatch) =>
leftMatch.keyframe.time - rightMatch.keyframe.time || leftMatch.keyframe.time - rightMatch.keyframe.time ||
leftMatch.componentIndex - rightMatch.componentIndex, leftMatch.componentIndex - rightMatch.componentIndex,
); );
const uniqueMatches: BindingKeyframeMatch[] = []; const uniqueMatches: ChannelKeyframeMatch[] = [];
for (const match of sortedMatches) { for (const match of sortedMatches) {
const previousMatch = uniqueMatches[uniqueMatches.length - 1]; const previousMatch = uniqueMatches[uniqueMatches.length - 1];
@@ -92,11 +92,11 @@ function getUniqueBindingKeyframeMatches({
return uniqueMatches; return uniqueMatches;
} }
function getPreferredBindingKeyframeMatch({ function getPreferredChannelKeyframeMatch({
matches, matches,
}: { }: {
matches: BindingKeyframeMatch[]; matches: ChannelKeyframeMatch[];
}): BindingKeyframeMatch | null { }): ChannelKeyframeMatch | null {
return ( return (
matches.find((match) => match.componentIndex === 0) ?? matches.find((match) => match.componentIndex === 0) ??
matches[0] ?? matches[0] ??
@@ -104,35 +104,67 @@ function getPreferredBindingKeyframeMatch({
); );
} }
function getComposedBindingValueAtTime({ function getChannelValue({
animations, channel,
binding,
time, time,
}: { }: {
animations: ElementAnimations; channel: AnimationChannel;
binding: AnimationBindingInstance;
time: number; time: number;
}) { }) {
const componentValues = Object.fromEntries( const fallbackValue = getChannelFallbackValue({ channel });
binding.components.map((component) => { if (typeof fallbackValue === "number") {
const channel = animations.channels[component.channelId]; return getChannelValueAtTime({
return [ channel: isScalarChannel(channel) ? channel : undefined,
component.key, time,
getChannelValueAtTime({ fallbackValue,
channel, });
time, }
fallbackValue: getBindingFallbackValue({ channel }), return getChannelValueAtTime({
}), channel: !isScalarChannel(channel) ? channel : undefined,
]; time,
}), fallbackValue,
) as Record<string, AnimationComponentValue | undefined>;
return composeAnimationValue({
binding,
componentValues,
}); });
} }
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({ function getKeyframeInterpolation({
channel, channel,
keyframe, keyframe,
@@ -140,25 +172,22 @@ function getKeyframeInterpolation({
channel: AnimationChannel; channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number]; keyframe: AnimationChannel["keys"][number];
}) { }) {
return channel.kind === "scalar" && "segmentToNext" in keyframe return isScalarChannel(channel) && "segmentToNext" in keyframe
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
: "hold"; : "hold";
} }
function toElementKeyframe({ function toElementKeyframe({
animations, data,
binding,
propertyPath, propertyPath,
keyframeMatch, keyframeMatch,
}: { }: {
animations: ElementAnimations; data: ChannelData | undefined;
binding: AnimationBindingInstance;
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeMatch: BindingKeyframeMatch; keyframeMatch: ChannelKeyframeMatch;
}): ElementKeyframe | null { }): ElementKeyframe | null {
const value = getComposedBindingValueAtTime({ const value = getComposedChannelDataValueAtTime({
animations, data,
binding,
time: keyframeMatch.keyframe.time, time: keyframeMatch.keyframe.time,
}); });
if (value === null) { if (value === null) {
@@ -186,19 +215,19 @@ export function getElementKeyframes({
return []; return [];
} }
return Object.entries(animations.bindings).flatMap( return Object.entries(animations).filter(([key]) =>
([propertyPath, binding]) => { isAnimationStorageKey({ key }),
if (!binding || !isAnimationPath(propertyPath)) { ).flatMap(
([propertyPath, data]) => {
if (!data || !isAnimationPath(propertyPath)) {
return []; return [];
} }
return getUniqueBindingKeyframeMatches({ return getUniqueChannelKeyframeMatches({
animations, data,
binding,
}).flatMap((keyframeMatch) => { }).flatMap((keyframeMatch) => {
const keyframe = toElementKeyframe({ const keyframe = toElementKeyframe({
animations, data,
binding,
propertyPath, propertyPath,
keyframeMatch, keyframeMatch,
}); });
@@ -219,13 +248,8 @@ export function hasKeyframesForPath({
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPath; propertyPath: AnimationPath;
}): boolean { }): boolean {
const binding = animations?.bindings[propertyPath]; return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some(
if (!binding) { ([, channel]) => channel.keys.length > 0,
return false;
}
return binding.components.some((component) =>
Boolean(animations?.channels[component.channelId]?.keys.length),
); );
} }
@@ -238,24 +262,22 @@ export function getKeyframeAtTime({
propertyPath: AnimationPath; propertyPath: AnimationPath;
time: number; time: number;
}): ElementKeyframe | null { }): ElementKeyframe | null {
const binding = animations?.bindings[propertyPath]; const data = animations?.[propertyPath];
if (!binding) { if (!data) {
return null; return null;
} }
const keyframeMatch = getPreferredBindingKeyframeMatch({ const keyframeMatch = getPreferredChannelKeyframeMatch({
matches: getBindingKeyframeMatches({ matches: getChannelKeyframeMatches({ data }).filter(
animations, ({ keyframe }) => keyframe.time === time,
binding, ),
}).filter(({ keyframe }) => keyframe.time === time),
}); });
if (!keyframeMatch) { if (!keyframeMatch) {
return null; return null;
} }
return toElementKeyframe({ return toElementKeyframe({
animations, data,
binding,
propertyPath, propertyPath,
keyframeMatch, keyframeMatch,
}); });
@@ -270,24 +292,22 @@ export function getKeyframeById({
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
}): ElementKeyframe | null { }): ElementKeyframe | null {
const binding = animations?.bindings[propertyPath]; const data = animations?.[propertyPath];
if (!binding) { if (!data) {
return null; return null;
} }
const keyframeMatch = getPreferredBindingKeyframeMatch({ const keyframeMatch = getPreferredChannelKeyframeMatch({
matches: getBindingKeyframeMatches({ matches: getChannelKeyframeMatches({ data }).filter(
animations, ({ keyframe }) => keyframe.id === keyframeId,
binding, ),
}).filter(({ keyframe }) => keyframe.id === keyframeId),
}); });
if (!keyframeMatch) { if (!keyframeMatch) {
return null; return null;
} }
return toElementKeyframe({ return toElementKeyframe({
animations, data,
binding,
propertyPath, propertyPath,
keyframeMatch, keyframeMatch,
}); });
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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<string>(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)
);
}
-390
View File
@@ -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<Record<string, NumericSpec>>;
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
setValue: ({
element,
value,
}: {
element: TimelineElement;
value: AnimationValue;
}) => TimelineElement;
}
function applyNumericSpec({
value,
numericRange,
}: {
value: number;
numericRange: NumericSpec | undefined;
}): number {
if (!numericRange) {
return value;
}
const steppedValue =
numericRange.step != null
? snapToStep({ value, step: numericRange.step })
: value;
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
function coerceNumberValue({
value,
numericRange,
}: {
value: AnimationValue;
numericRange?: NumericSpec;
}): number | null {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
return applyNumericSpec({ value, numericRange });
}
function coerceColorValue({
value,
}: {
value: AnimationValue;
}): string | null {
return typeof value === "string" && parseColorToLinearRgba({ color: value })
? value
: null;
}
function createNumberPropertyDefinition({
numericRange,
supportsElement,
getValue,
setValue,
}: {
numericRange?: NumericSpec;
supportsElement: AnimationPropertyDefinition["supportsElement"];
getValue: AnimationPropertyDefinition["getValue"];
setValue: AnimationPropertyDefinition["setValue"];
}): AnimationPropertyDefinition {
return {
kind: "number",
defaultInterpolation: "linear",
numericRanges: numericRange ? { value: numericRange } : undefined,
supportsElement,
getValue,
coerceValue: ({ value }) =>
coerceNumberValue({
value,
numericRange,
}),
setValue,
};
}
const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
"transform.positionX": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.x : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, x: value as number },
},
}
: element,
}),
"transform.positionY": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.y : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, y: value as number },
},
}
: element,
}),
"transform.scaleX": createNumberPropertyDefinition({
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scaleX : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, scaleX: value as number },
}
: element,
}),
"transform.scaleY": createNumberPropertyDefinition({
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.scaleY : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, scaleY: value as number },
}
: element,
}),
"transform.rotate": createNumberPropertyDefinition({
numericRange: { min: -360, max: 360, step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.rotate : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: { ...element.transform, rotate: value as number },
}
: element,
}),
opacity: createNumberPropertyDefinition({
numericRange: { min: 0, max: 1, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? { ...element, opacity: value as number }
: element,
}),
volume: createNumberPropertyDefinition({
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
supportsElement: ({ element }) => canElementHaveAudio(element),
getValue: ({ element }) =>
canElementHaveAudio(element) ? element.volume ?? 0 : null,
setValue: ({ element, value }) =>
canElementHaveAudio(element)
? { ...element, volume: value as number }
: element,
}),
color: {
kind: "color",
defaultInterpolation: "linear",
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) => (element.type === "text" ? element.color : null),
coerceValue: ({ value }) => coerceColorValue({ value }),
setValue: ({ element, value }) =>
element.type === "text"
? { ...element, color: value as string }
: element,
},
"background.color": {
kind: "color",
defaultInterpolation: "linear",
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text" ? element.background.color : null,
coerceValue: ({ value }) => coerceColorValue({ value }),
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, color: value as string },
}
: element,
},
"background.paddingX": createNumberPropertyDefinition({
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, paddingX: value as number },
}
: element,
}),
"background.paddingY": createNumberPropertyDefinition({
numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, paddingY: value as number },
}
: element,
}),
"background.offsetX": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, offsetX: value as number },
}
: element,
}),
"background.offsetY": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, offsetY: value as number },
}
: element,
}),
"background.cornerRadius": createNumberPropertyDefinition({
numericRange: {
min: CORNER_RADIUS_MIN,
max: CORNER_RADIUS_MAX,
step: 1,
},
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
: null,
setValue: ({ element, value }) =>
element.type === "text"
? {
...element,
background: { ...element.background, cornerRadius: value as number },
}
: element,
}),
};
export function 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 });
}
+112 -143
View File
@@ -1,19 +1,13 @@
import type { import type {
AnimationColorPropertyPath,
AnimationNumericPropertyPath,
AnimationPath, AnimationPath,
AnimationPropertyPath,
AnimationValueForPath,
ElementAnimations, ElementAnimations,
} from "@/animation/types"; } from "@/animation/types";
import type { Transform } from "@/rendering"; import { formatLinearRgba, parseColorToLinearRgba } from "@/params";
import { import type { ParamValue } from "@/params";
type AnimationComponentValue, import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
composeAnimationValue,
decomposeAnimationValue,
} from "./binding-values";
import { import {
getChannelValueAtTime, getChannelValueAtTime,
isScalarChannel,
} from "./interpolation"; } from "./interpolation";
export function getElementLocalTime({ export function getElementLocalTime({
@@ -37,148 +31,123 @@ export function getElementLocalTime({
return localTime; return localTime;
} }
export function resolveTransformAtTime({ export function resolveAnimationPathValueAtTime({
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<TPath extends AnimationPath>({
animations, animations,
propertyPath, propertyPath,
localTime, localTime,
fallbackValue, fallbackValue,
}: { }: {
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: TPath; propertyPath: AnimationPath;
localTime: number; localTime: number;
fallbackValue: AnimationValueForPath<TPath>; fallbackValue: number;
}): AnimationValueForPath<TPath> { }): number;
const binding = animations?.bindings[propertyPath]; export function resolveAnimationPathValueAtTime({
if (!binding) { 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; return fallbackValue;
} }
const fallbackComponents = decomposeAnimationValue({ if (
kind: binding.kind, typeof fallbackValue !== "string" ||
value: fallbackValue, !("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<string, AnimationComponentValue | undefined>;
return (composeAnimationValue({
binding,
componentValues,
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
} }
-285
View File
@@ -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<Record<string, NumericSpec>>;
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<Record<string, NumericSpec>> | 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;
}
+42 -107
View File
@@ -1,4 +1,5 @@
import type { ParamValues } from "@/params"; import type { MediaTime } from "@/wasm";
import type { ParamValue } from "@/params";
export const ANIMATION_PROPERTY_PATHS = [ export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX", "transform.positionX",
@@ -20,10 +21,7 @@ export const ANIMATION_PROPERTY_PATHS = [
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
export type GraphicParamPath = `params.${string}`; export type GraphicParamPath = `params.${string}`;
export type EffectParamPath = `effects.${string}.params.${string}`; export type EffectParamPath = `effects.${string}.params.${string}`;
export type AnimationPath = export type AnimationPath = string;
| AnimationPropertyPath
| GraphicParamPath
| EffectParamPath;
export const ANIMATION_PROPERTY_GROUPS = { export const ANIMATION_PROPERTY_GROUPS = {
"transform.scale": ["transform.scaleX", "transform.scaleY"], "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 AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
export type VectorValue = { x: number; y: number };
export type DiscreteValue = boolean | string; export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue;
export interface AnimationPropertyValueMap { export interface NumericSpec {
"transform.positionX": number; min?: number;
"transform.positionY": number; max?: number;
"transform.scaleX": number; step?: number;
"transform.scaleY": number;
"transform.rotate": number;
opacity: number;
volume: number;
color: string;
"background.color": string;
"background.paddingX": number;
"background.paddingY": number;
"background.offsetX": number;
"background.offsetY": number;
"background.cornerRadius": number;
} }
export type DynamicAnimationPathValue = ParamValues[string]; export type AnimationColorPropertyPath = Extract<
export type AnimationValueForPath<TPath extends AnimationPath> = AnimationPropertyPath,
TPath extends AnimationPropertyPath "color" | "background.color"
? AnimationPropertyValueMap[TPath] >;
: TPath extends GraphicParamPath | EffectParamPath export type AnimationNumericPropertyPath = Exclude<
? DynamicAnimationPathValue AnimationPropertyPath,
: never; AnimationColorPropertyPath
export type AnimationNumericPropertyPath = { >;
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
}[AnimationPropertyPath];
export type AnimationColorPropertyPath = {
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
}[AnimationPropertyPath];
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
export type DiscreteKeyframeInterpolation = "hold"; export type DiscreteKeyframeInterpolation = "hold";
@@ -70,20 +51,18 @@ export type AnimationInterpolation =
| ContinuousKeyframeInterpolation | ContinuousKeyframeInterpolation
| DiscreteKeyframeInterpolation; | DiscreteKeyframeInterpolation;
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
export type ScalarSegmentType = "step" | "linear" | "bezier"; export type ScalarSegmentType = "step" | "linear" | "bezier";
export type TangentMode = "auto" | "aligned" | "broken" | "flat"; export type TangentMode = "auto" | "aligned" | "broken" | "flat";
export type ChannelExtrapolationMode = "hold" | "linear"; export type ChannelExtrapolationMode = "hold" | "linear";
export interface CurveHandle { export interface CurveHandle {
dt: number; dt: MediaTime;
dv: number; dv: number;
} }
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> { interface BaseAnimationKeyframe<TValue extends ParamValue> {
id: string; id: string;
time: number; // relative to element start time time: MediaTime; // relative to element start time
value: TValue; value: TValue;
} }
@@ -94,13 +73,16 @@ export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
tangentMode: TangentMode; tangentMode: TangentMode;
} }
export interface DiscreteAnimationKey export type DiscreteAnimationKey = BaseAnimationKeyframe<DiscreteValue>;
extends BaseAnimationKeyframe<DiscreteValue> {}
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey; export type Keyframe<TValue extends ParamValue = ParamValue> =
TValue extends number
? ScalarAnimationKey
: TValue extends DiscreteValue
? DiscreteAnimationKey
: never;
export interface ScalarAnimationChannel { export interface ScalarChannel {
kind: "scalar";
keys: ScalarAnimationKey[]; keys: ScalarAnimationKey[];
extrapolation?: { extrapolation?: {
before: ChannelExtrapolationMode; before: ChannelExtrapolationMode;
@@ -108,72 +90,26 @@ export interface ScalarAnimationChannel {
}; };
} }
export interface DiscreteAnimationChannel { export interface DiscreteChannel {
kind: "discrete";
keys: DiscreteAnimationKey[]; keys: DiscreteAnimationKey[];
} }
export type AnimationChannel = export type Channel<TValue extends ParamValue = ParamValue> =
| ScalarAnimationChannel TValue extends number
| DiscreteAnimationChannel; ? ScalarChannel
: TValue extends DiscreteValue
? DiscreteChannel
: never;
export type ElementAnimationChannelMap = Record< export type ScalarAnimationChannel = Channel<number>;
string, export type DiscreteAnimationChannel = Channel<DiscreteValue>;
AnimationChannel | undefined export type AnimationChannel = Channel;
>;
export interface AnimationBindingComponent<TKey extends string = string> { export type CompositeChannelData = Record<string, AnimationChannel | undefined>;
key: TKey; export type ChannelData = AnimationChannel | CompositeChannelData;
channelId: string;
}
interface BaseAnimationBinding<
TKind extends AnimationBindingKind,
TComponentKey extends string,
> {
path: AnimationPath;
kind: TKind;
components: AnimationBindingComponent<TComponentKey>[];
}
export interface NumberAnimationBinding
extends BaseAnimationBinding<"number", "value"> {}
export interface Vector2AnimationBinding
extends BaseAnimationBinding<"vector2", "x" | "y"> {}
export interface ColorAnimationBinding
extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
colorSpace: "srgb-linear";
}
export interface DiscreteAnimationBinding
extends BaseAnimationBinding<"discrete", "value"> {}
export type AnimationBindingInstance =
| NumberAnimationBinding
| Vector2AnimationBinding
| ColorAnimationBinding
| DiscreteAnimationBinding;
export interface AnimationBindingByKind {
number: NumberAnimationBinding;
vector2: Vector2AnimationBinding;
color: ColorAnimationBinding;
discrete: DiscreteAnimationBinding;
}
export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
AnimationBindingByKind[TKind];
export type ElementAnimationBindingMap = Record<
string,
AnimationBindingInstance | undefined
>;
export interface ElementAnimations { export interface ElementAnimations {
bindings: ElementAnimationBindingMap; [propertyPath: AnimationPath]: ChannelData | undefined;
channels: ElementAnimationChannelMap;
} }
export type NormalizedCubicBezier = [number, number, number, number]; export type NormalizedCubicBezier = [number, number, number, number];
@@ -181,7 +117,6 @@ export type NormalizedCubicBezier = [number, number, number, number];
export interface ScalarGraphChannelTarget { export interface ScalarGraphChannelTarget {
propertyPath: AnimationPath; propertyPath: AnimationPath;
componentKey: string; componentKey: string;
channelId: string;
} }
export interface ScalarGraphChannel extends ScalarGraphChannelTarget { export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
@@ -209,8 +144,8 @@ export interface ScalarCurveKeyframePatch {
export interface ElementKeyframe { export interface ElementKeyframe {
propertyPath: AnimationPath; propertyPath: AnimationPath;
id: string; id: string;
time: number; time: MediaTime;
value: AnimationValue; value: ParamValue;
interpolation: AnimationInterpolation; interpolation: AnimationInterpolation;
} }
+61
View File
@@ -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,
});
}
+1 -1
View File
@@ -190,7 +190,7 @@ export default function BrandPage() {
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2 className="font-semibold text-lg">What's not allowed</h2> <h2 className="font-semibold text-lg">What&apos;s not allowed</h2>
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed"> <ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
{[ {[
"Using OpenCut in the name of your product, service, or domain.", "Using OpenCut in the name of your product, service, or domain.",
+14 -5
View File
@@ -130,8 +130,14 @@ function EditorLayout() {
direction="vertical" direction="vertical"
className="size-full gap-[0.18rem]" className="size-full gap-[0.18rem]"
onLayout={(sizes) => { onLayout={(sizes) => {
setPanel("mainContent", sizes[0] ?? panels.mainContent); setPanel({
setPanel("timeline", sizes[1] ?? panels.timeline); panel: "mainContent",
size: sizes[0] ?? panels.mainContent,
});
setPanel({
panel: "timeline",
size: sizes[1] ?? panels.timeline,
});
}} }}
> >
<ResizablePanel <ResizablePanel
@@ -144,9 +150,12 @@ function EditorLayout() {
direction="horizontal" direction="horizontal"
className="size-full gap-[0.19rem] px-3" className="size-full gap-[0.19rem] px-3"
onLayout={(sizes) => { onLayout={(sizes) => {
setPanel("tools", sizes[0] ?? panels.tools); setPanel({ panel: "tools", size: sizes[0] ?? panels.tools });
setPanel("preview", sizes[1] ?? panels.preview); setPanel({ panel: "preview", size: sizes[1] ?? panels.preview });
setPanel("properties", sizes[2] ?? panels.properties); setPanel({
panel: "properties",
size: sizes[2] ?? panels.properties,
});
}} }}
> >
<ResizablePanel <ResizablePanel
+7 -4
View File
@@ -57,7 +57,10 @@ export default function PrivacyPage() {
content is tracked content is tracked
</li> </li>
<li>You can clear local data from your browser at any time</li> <li>You can clear local data from your browser at any time</li>
<li>We don't sell or share your data with anyone (we don't even have it)</li> <li>
We don&apos;t sell or share your data with anyone (we don&apos;t
even have it)
</li>
</ol> </ol>
<p className="mt-4"> <p className="mt-4">
Questions? Email us at{" "} Questions? Email us at{" "}
@@ -172,7 +175,7 @@ export default function PrivacyPage() {
<a <a
href={SOCIAL_LINKS.github} href={SOCIAL_LINKS.github}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
GitHub GitHub
@@ -189,7 +192,7 @@ export default function PrivacyPage() {
<a <a
href={`${SOCIAL_LINKS.github}/issues`} href={`${SOCIAL_LINKS.github}/issues`}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
GitHub repository GitHub repository
@@ -205,7 +208,7 @@ export default function PrivacyPage() {
<a <a
href={SOCIAL_LINKS.x} href={SOCIAL_LINKS.x}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
X (Twitter) X (Twitter)
+16 -12
View File
@@ -51,9 +51,13 @@ export default function TermsPage() {
Free for personal and commercial use with no watermarks or Free for personal and commercial use with no watermarks or
restrictions restrictions
</li> </li>
<li>You're responsible for how you use it - don't break the law</li>
<li> <li>
Service provided "as is" - we can't guarantee perfect uptime You&apos;re responsible for how you use it - don&apos;t break
the law
</li>
<li>
Service provided &quot;as is&quot; - we can&apos;t guarantee
perfect uptime
</li> </li>
<li> <li>
Open source means you can review our code and self-host if Open source means you can review our code and self-host if
@@ -109,8 +113,8 @@ export default function TermsPage() {
</li> </li>
</ul> </ul>
<p> <p>
You're responsible for how you use OpenCut and the content you create. You&apos;re responsible for how you use OpenCut and the content you
Don't use it for anything illegal in your jurisdiction. create. Don&apos;t use it for anything illegal in your jurisdiction.
</p> </p>
</section> </section>
@@ -127,8 +131,8 @@ export default function TermsPage() {
<h2 className="text-2xl font-semibold">Service</h2> <h2 className="text-2xl font-semibold">Service</h2>
<p> <p>
OpenCut does not currently require an account. The service is provided OpenCut does not currently require an account. The service is provided
"as is" without warranties. While we strive for reliability, we can't &quot;as is&quot; without warranties. While we strive for
guarantee uninterrupted service. reliability, we can&apos;t guarantee uninterrupted service.
</p> </p>
</section> </section>
@@ -146,7 +150,7 @@ export default function TermsPage() {
<a <a
href={SOCIAL_LINKS.github} href={SOCIAL_LINKS.github}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
GitHub GitHub
@@ -161,12 +165,12 @@ export default function TermsPage() {
OpenCut is provided free of charge. To the extent permitted by law: OpenCut is provided free of charge. To the extent permitted by law:
</p> </p>
<ul className="list-disc space-y-2 pl-6"> <ul className="list-disc space-y-2 pl-6">
<li>We're not liable for any loss of data or content</li> <li>We&apos;re not liable for any loss of data or content</li>
<li> <li>
Projects are stored in your browser and may be lost if you clear Projects are stored in your browser and may be lost if you clear
browser data browser data
</li> </li>
<li>We're not responsible for how you use the service</li> <li>We&apos;re not responsible for how you use the service</li>
<li>Our liability is limited to the maximum extent allowed by law</li> <li>Our liability is limited to the maximum extent allowed by law</li>
</ul> </ul>
<p> <p>
@@ -180,7 +184,7 @@ export default function TermsPage() {
<h2 className="text-2xl font-semibold">Service Changes</h2> <h2 className="text-2xl font-semibold">Service Changes</h2>
<p>We may update OpenCut and these terms:</p> <p>We may update OpenCut and these terms:</p>
<ul className="list-disc space-y-2 pl-6"> <ul className="list-disc space-y-2 pl-6">
<li>We'll notify you of significant changes to these terms</li> <li>We&apos;ll notify you of significant changes to these terms</li>
<li>Continued use means you accept any updates</li> <li>Continued use means you accept any updates</li>
<li>You can always self-host an older version if you prefer</li> <li>You can always self-host an older version if you prefer</li>
<li>Major changes will be discussed with the community on GitHub</li> <li>Major changes will be discussed with the community on GitHub</li>
@@ -203,7 +207,7 @@ export default function TermsPage() {
<a <a
href={`${SOCIAL_LINKS.github}/issues`} href={`${SOCIAL_LINKS.github}/issues`}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
GitHub repository GitHub repository
@@ -219,7 +223,7 @@ export default function TermsPage() {
<a <a
href={SOCIAL_LINKS.x} href={SOCIAL_LINKS.x}
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
X (Twitter) X (Twitter)
+1 -1
View File
@@ -35,7 +35,7 @@ export const ElementsClipboardHandler = {
}; };
}, },
paste(entry, context) { paste({ entry, context }) {
if (entry.items.length === 0) { if (entry.items.length === 0) {
return null; return null;
} }
+1 -1
View File
@@ -45,5 +45,5 @@ export function buildPasteClipboardCommand({
const handler = clipboardHandlers[entry.type] as ClipboardHandler< const handler = clipboardHandlers[entry.type] as ClipboardHandler<
typeof entry.type typeof entry.type
>; >;
return handler.paste(entry as never, context); return handler.paste({ entry: entry as never, context });
} }
+15 -9
View File
@@ -1,4 +1,6 @@
import { getKeyframeById } from "@/animation"; import { getKeyframeById } from "@/animation";
import { getChannelEntriesFromData } from "@/animation/channel-data";
import { isScalarChannel } from "@/animation/interpolation";
import type { SelectedKeyframeRef } from "@/animation/types"; import type { SelectedKeyframeRef } from "@/animation/types";
import type { TimelineElement } from "@/timeline"; import type { TimelineElement } from "@/timeline";
import { PasteKeyframesCommand } from "@/commands/timeline"; import { PasteKeyframesCommand } from "@/commands/timeline";
@@ -7,6 +9,7 @@ import type {
KeyframeClipboardCurvePatch, KeyframeClipboardCurvePatch,
KeyframeClipboardItem, KeyframeClipboardItem,
} from "../types"; } from "../types";
import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm";
function resolveSingleSourceElement({ function resolveSingleSourceElement({
selectedKeyframes, selectedKeyframes,
@@ -40,14 +43,13 @@ function getCurvePatches({
propertyPath: KeyframeClipboardItem["propertyPath"]; propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string; keyframeId: string;
}): KeyframeClipboardCurvePatch[] { }): KeyframeClipboardCurvePatch[] {
const binding = element.animations?.bindings[propertyPath]; const data = element.animations?.[propertyPath];
if (!binding) { if (!data) {
return []; return [];
} }
return binding.components.flatMap((component) => { return getChannelEntriesFromData({ data }).flatMap(([componentKey, channel]) => {
const channel = element.animations?.channels[component.channelId]; if (!channel || !isScalarChannel(channel)) {
if (channel?.kind !== "scalar") {
return []; return [];
} }
@@ -60,7 +62,7 @@ function getCurvePatches({
return [ return [
{ {
componentKey: component.key, componentKey,
patch: { patch: {
leftHandle: keyframe.leftHandle ?? null, leftHandle: keyframe.leftHandle ?? null,
rightHandle: keyframe.rightHandle ?? null, rightHandle: keyframe.rightHandle ?? null,
@@ -80,7 +82,7 @@ function buildClipboardItem({
element: TimelineElement; element: TimelineElement;
propertyPath: KeyframeClipboardItem["propertyPath"]; propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string; keyframeId: string;
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null { }): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null {
const keyframe = getKeyframeById({ const keyframe = getKeyframeById({
animations: element.animations, animations: element.animations,
propertyPath, propertyPath,
@@ -136,10 +138,11 @@ export const KeyframesClipboardHandler = {
} }
const minTime = Math.min(...rawItems.map((item) => item.time)); const minTime = Math.min(...rawItems.map((item) => item.time));
const minTimeMedia = roundMediaTime({ time: minTime });
const items = rawItems const items = rawItems
.map(({ time, ...item }) => ({ .map(({ time, ...item }) => ({
...item, ...item,
timeOffset: time - minTime, timeOffset: subMediaTime({ a: time, b: minTimeMedia }),
})) }))
.sort( .sort(
(left, right) => (left, right) =>
@@ -154,7 +157,10 @@ export const KeyframesClipboardHandler = {
}; };
}, },
paste(entry, { selectedElements, time }) { paste({
entry,
context: { selectedElements, time },
}) {
const targetElement = selectedElements[0]; const targetElement = selectedElements[0];
if (!targetElement || entry.items.length === 0) { if (!targetElement || entry.items.length === 0) {
return null; return null;
+9 -8
View File
@@ -2,16 +2,17 @@ import type { EditorCore } from "@/core";
import type { import type {
AnimationInterpolation, AnimationInterpolation,
AnimationPath, AnimationPath,
AnimationValue,
ScalarCurveKeyframePatch, ScalarCurveKeyframePatch,
SelectedKeyframeRef, SelectedKeyframeRef,
} from "@/animation/types"; } from "@/animation/types";
import type { ParamValue } from "@/params";
import type { Command } from "@/commands/base-command"; import type { Command } from "@/commands/base-command";
import type { import type {
CreateTimelineElement, CreateTimelineElement,
ElementRef, ElementRef,
TrackType, TrackType,
} from "@/timeline"; } from "@/timeline";
import type { MediaTime } from "@/wasm";
export interface ElementClipboardItem { export interface ElementClipboardItem {
trackId: string; trackId: string;
@@ -26,8 +27,8 @@ export interface KeyframeClipboardCurvePatch {
export interface KeyframeClipboardItem { export interface KeyframeClipboardItem {
propertyPath: AnimationPath; propertyPath: AnimationPath;
timeOffset: number; timeOffset: MediaTime;
value: AnimationValue; value: ParamValue;
interpolation: AnimationInterpolation; interpolation: AnimationInterpolation;
curvePatches: KeyframeClipboardCurvePatch[]; curvePatches: KeyframeClipboardCurvePatch[];
} }
@@ -61,17 +62,17 @@ export interface PasteContext {
editor: EditorCore; editor: EditorCore;
selectedElements: ElementRef[]; selectedElements: ElementRef[];
selectedKeyframes: SelectedKeyframeRef[]; selectedKeyframes: SelectedKeyframeRef[];
time: number; time: MediaTime;
} }
export interface ClipboardHandler<TType extends ClipboardEntryType> { export interface ClipboardHandler<TType extends ClipboardEntryType> {
type: TType; type: TType;
canCopy(context: CopyContext): boolean; canCopy(context: CopyContext): boolean;
copy(context: CopyContext): ClipboardEntryByType[TType] | null; copy(context: CopyContext): ClipboardEntryByType[TType] | null;
paste( paste(args: {
entry: ClipboardEntryByType[TType], entry: ClipboardEntryByType[TType];
context: PasteContext, context: PasteContext;
): Command | null; }): Command | null;
} }
export type ClipboardHandlerMap = { export type ClipboardHandlerMap = {
+20 -5
View File
@@ -16,14 +16,22 @@ export class AddMediaAssetCommand extends Command {
private previousProjectFps: FrameRate | null = null; private previousProjectFps: FrameRate | null = null;
private appliedProjectFps: FrameRate | null = null; private appliedProjectFps: FrameRate | null = null;
constructor( constructor({
private projectId: string, projectId,
private asset: Omit<MediaAsset, "id">, asset,
) { }: {
projectId: string;
asset: Omit<MediaAsset, "id">;
}) {
super(); super();
this.projectId = projectId;
this.asset = asset;
this.assetId = generateUUID(); this.assetId = generateUUID();
} }
private projectId: string;
private asset: Omit<MediaAsset, "id">;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedAssets = [...editor.media.getAssets()]; this.savedAssets = [...editor.media.getAssets()];
@@ -117,7 +125,14 @@ export class AddMediaAssetCommand extends Command {
const activeProject = editor.project.getActiveOrNull(); const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return; if (!activeProject) return;
if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return; if (
!this.appliedProjectFps ||
!frameRatesEqual({
a: activeProject.settings.fps,
b: this.appliedProjectFps,
})
)
return;
const highestRemainingVideoFps = getHighestImportedVideoFps({ const highestRemainingVideoFps = getHighestImportedVideoFps({
mediaAssets: editor.media.getAssets(), mediaAssets: editor.media.getAssets(),
@@ -13,13 +13,21 @@ export class RemoveMediaAssetCommand extends Command {
private savedTracks: SceneTracks | null = null; private savedTracks: SceneTracks | null = null;
private removedAsset: MediaAsset | null = null; private removedAsset: MediaAsset | null = null;
constructor( constructor({
private projectId: string, projectId,
private assetId: string, assetId,
) { }: {
projectId: string;
assetId: string;
}) {
super(); super();
this.projectId = projectId;
this.assetId = assetId;
} }
private projectId: string;
private assetId: string;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const assets = editor.media.getAssets(); const assets = editor.media.getAssets();
+12 -4
View File
@@ -7,13 +7,21 @@ export class CreateSceneCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private createdScene: TScene | null = null; private createdScene: TScene | null = null;
constructor( constructor({
private name: string, name,
private isMain: boolean = false, isMain = false,
) { }: {
name: string;
isMain?: boolean;
}) {
super(); super();
this.name = name;
this.isMain = isMain;
} }
private name: string;
private isMain: boolean;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedScenes = [...editor.scenes.getScenes()]; this.savedScenes = [...editor.scenes.getScenes()];
+13 -4
View File
@@ -3,17 +3,26 @@ import { EditorCore } from "@/core";
import type { TScene } from "@/timeline"; import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/timeline/scenes"; import { updateSceneInArray } from "@/timeline/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index"; import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
import type { MediaTime } from "@/wasm";
export class MoveBookmarkCommand extends Command { export class MoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
constructor( constructor({
private fromTime: number, fromTime,
private toTime: number, toTime,
) { }: {
fromTime: MediaTime;
toTime: MediaTime;
}) {
super(); super();
this.fromTime = fromTime;
this.toTime = toTime;
} }
private fromTime: MediaTime;
private toTime: MediaTime;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();
@@ -6,12 +6,13 @@ import {
getFrameTime, getFrameTime,
removeBookmarkFromArray, removeBookmarkFromArray,
} from "@/timeline/bookmarks/index"; } from "@/timeline/bookmarks/index";
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export class RemoveBookmarkCommand extends Command { export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private frameTime: number = 0; private frameTime: MediaTime = ZERO_MEDIA_TIME;
constructor(private time: number) { constructor(private time: MediaTime) {
super(); super();
} }
+12 -4
View File
@@ -7,13 +7,21 @@ export class RenameSceneCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private previousName: string | null = null; private previousName: string | null = null;
constructor( constructor({
private sceneId: string, sceneId,
private newName: string, newName,
) { }: {
sceneId: string;
newName: string;
}) {
super(); super();
this.sceneId = sceneId;
this.newName = newName;
} }
private sceneId: string;
private newName: string;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes(); const scenes = editor.scenes.getScenes();
@@ -6,12 +6,13 @@ import {
getFrameTime, getFrameTime,
toggleBookmarkInArray, toggleBookmarkInArray,
} from "@/timeline/bookmarks/index"; } from "@/timeline/bookmarks/index";
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export class ToggleBookmarkCommand extends Command { export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private frameTime: number = 0; private frameTime: MediaTime = ZERO_MEDIA_TIME;
constructor(private time: number) { constructor(private time: MediaTime) {
super(); super();
} }
+13 -4
View File
@@ -6,17 +6,26 @@ import {
getFrameTime, getFrameTime,
updateBookmarkInArray, updateBookmarkInArray,
} from "@/timeline/bookmarks/index"; } from "@/timeline/bookmarks/index";
import type { MediaTime } from "@/wasm";
export class UpdateBookmarkCommand extends Command { export class UpdateBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
constructor( constructor({
private time: number, time,
private updates: Partial<Omit<Bookmark, "time">>, updates,
) { }: {
time: MediaTime;
updates: Partial<Omit<Bookmark, "time">>;
}) {
super(); super();
this.time = time;
this.updates = updates;
} }
private time: MediaTime;
private updates: Partial<Omit<Bookmark, "time">>;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();
@@ -1,7 +1,6 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { import {
getKeyframeAtTime, getKeyframeAtTime,
resolveAnimationTarget,
updateScalarKeyframeCurve, updateScalarKeyframeCurve,
upsertPathKeyframe, upsertPathKeyframe,
} from "@/animation"; } from "@/animation";
@@ -9,7 +8,15 @@ import { Command, type CommandResult } from "@/commands/base-command";
import type { KeyframeClipboardItem } from "@/clipboard"; import type { KeyframeClipboardItem } from "@/clipboard";
import type { SceneTracks, TimelineElement } from "@/timeline"; import type { SceneTracks, TimelineElement } from "@/timeline";
import { updateElementInSceneTracks } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import {
addMediaTime,
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
function pasteKeyframesIntoElement({ function pasteKeyframesIntoElement({
element, element,
@@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({
clipboardItems, clipboardItems,
}: { }: {
element: TimelineElement; element: TimelineElement;
time: number; time: MediaTime;
clipboardItems: KeyframeClipboardItem[]; clipboardItems: KeyframeClipboardItem[];
}): TimelineElement { }): TimelineElement {
let nextElement = element; let nextElement = element;
@@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({
continue; continue;
} }
const keyframeTime = Math.max( const keyframeTime = maxMediaTime({
0, a: ZERO_MEDIA_TIME,
Math.min(time + item.timeOffset, nextElement.duration), b: minMediaTime({
); a: addMediaTime({ a: time, b: item.timeOffset }),
b: nextElement.duration,
}),
});
const nextAnimations = upsertPathKeyframe({ const nextAnimations = upsertPathKeyframe({
animations: nextElement.animations, animations: nextElement.animations,
propertyPath: item.propertyPath, propertyPath: item.propertyPath,
@@ -42,8 +52,7 @@ function pasteKeyframesIntoElement({
value: item.value, value: item.value,
interpolation: item.interpolation, interpolation: item.interpolation,
keyframeId: generateUUID(), keyframeId: generateUUID(),
kind: target.kind, channelLayout: target.channelLayout,
defaultInterpolation: target.defaultInterpolation,
coerceValue: target.coerceValue, coerceValue: target.coerceValue,
}); });
const pastedKeyframe = getKeyframeAtTime({ const pastedKeyframe = getKeyframeAtTime({
@@ -79,7 +88,7 @@ export class PasteKeyframesCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly time: number; private readonly time: MediaTime;
private readonly clipboardItems: KeyframeClipboardItem[]; private readonly clipboardItems: KeyframeClipboardItem[];
constructor({ constructor({
@@ -90,7 +99,7 @@ export class PasteKeyframesCommand extends Command {
}: { }: {
trackId: string; trackId: string;
elementId: string; elementId: string;
time: number; time: MediaTime;
clipboardItems: KeyframeClipboardItem[]; clipboardItems: KeyframeClipboardItem[];
}) { }) {
super(); super();
@@ -13,18 +13,25 @@ import {
enforceMainTrackStart, enforceMainTrackStart,
} from "@/timeline/placement"; } from "@/timeline/placement";
import { cloneAnimations } from "@/animation"; import { cloneAnimations } from "@/animation";
import {
addMediaTime,
type MediaTime,
maxMediaTime,
subMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
export class PasteCommand extends Command { export class PasteCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private pastedElements: { trackId: string; elementId: string }[] = []; private pastedElements: { trackId: string; elementId: string }[] = [];
private readonly time: number; private readonly time: MediaTime;
private readonly clipboardItems: ElementClipboardItem[]; private readonly clipboardItems: ElementClipboardItem[];
constructor({ constructor({
time, time,
clipboardItems, clipboardItems,
}: { }: {
time: number; time: MediaTime;
clipboardItems: ElementClipboardItem[]; clipboardItems: ElementClipboardItem[];
}) { }) {
super(); super();
@@ -39,8 +46,12 @@ export class PasteCommand extends Command {
this.savedState = editor.scenes.getActiveScene().tracks; this.savedState = editor.scenes.getActiveScene().tracks;
this.pastedElements = []; this.pastedElements = [];
const minStart = Math.min( const minStart = this.clipboardItems.reduce(
...this.clipboardItems.map((item) => item.element.startTime), (earliestStartTime, item) =>
item.element.startTime < earliestStartTime
? item.element.startTime
: earliestStartTime,
this.clipboardItems[0].element.startTime,
); );
let updatedTracks = this.savedState; let updatedTracks = this.savedState;
@@ -97,12 +108,18 @@ export class PasteCommand extends Command {
targetTrackId: targetTrack.id, targetTrackId: targetTrack.id,
requestedStartTime: earliestElement.startTime, requestedStartTime: earliestElement.startTime,
}); });
const delta = adjustedEarliestStartTime - earliestElement.startTime; const delta = subMediaTime({
a: adjustedEarliestStartTime,
b: earliestElement.startTime,
});
if (delta !== 0) { if (delta !== ZERO_MEDIA_TIME) {
elementsForPlacement = elementsToAdd.map((element) => ({ elementsForPlacement = elementsToAdd.map((element) => ({
...element, ...element,
startTime: Math.max(0, element.startTime + delta), startTime: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: addMediaTime({ a: element.startTime, b: delta }),
}),
})); }));
} }
} }
@@ -168,14 +185,20 @@ function buildPastedElements({
time, time,
}: { }: {
items: ElementClipboardItem[]; items: ElementClipboardItem[];
minStart: number; minStart: MediaTime;
time: number; time: MediaTime;
}): TimelineElement[] { }): TimelineElement[] {
const elementsToAdd: TimelineElement[] = []; const elementsToAdd: TimelineElement[] = [];
for (const item of items) { for (const item of items) {
const relativeOffset = item.element.startTime - minStart; const relativeOffset = subMediaTime({
const startTime = Math.max(0, time + relativeOffset); a: item.element.startTime,
b: minStart,
});
const startTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: addMediaTime({ a: time, b: relativeOffset }),
});
const newElementId = generateUUID(); const newElementId = generateUUID();
elementsToAdd.push({ elementsToAdd.push({
@@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement"; import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
import { cloneAnimations } from "@/animation"; import { cloneAnimations } from "@/animation";
import type { MediaTime } from "@/wasm";
interface DuplicateElementsParams { interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[]; elements: { trackId: string; elementId: string }[];
@@ -119,7 +120,7 @@ function buildDuplicateElement({
}: { }: {
element: TimelineElement; element: TimelineElement;
id: string; id: string;
startTime: number; startTime: MediaTime;
}): TimelineElement { }): TimelineElement {
return { return {
...element, ...element,
@@ -18,6 +18,7 @@ import {
resolveTrackPlacement, resolveTrackPlacement,
validateElementTrackCompatibility, validateElementTrackCompatibility,
} from "@/timeline/placement"; } from "@/timeline/placement";
import { roundMediaTime } from "@/wasm";
type InsertElementPlacement = type InsertElementPlacement =
| { mode: "explicit"; trackId: string } | { mode: "explicit"; trackId: string }
@@ -188,7 +189,7 @@ export class InsertElementCommand extends Command {
} }
} }
if (element.type === "text" && !element.content) { if (element.type === "text" && !element.params.content) {
console.error("Text element must have content"); console.error("Text element must have content");
return false; return false;
} }
@@ -266,7 +267,12 @@ export class InsertElementCommand extends Command {
placementResult.kind === "existingTrack" placementResult.kind === "existingTrack"
? { ? {
...element, ...element,
startTime: placementResult.adjustedStartTime ?? element.startTime, startTime:
placementResult.adjustedStartTime !== undefined
? roundMediaTime({
time: placementResult.adjustedStartTime,
})
: element.startTime,
} }
: element; : element;
@@ -2,12 +2,13 @@ import { EditorCore } from "@/core";
import { import {
hasKeyframesForPath, hasKeyframesForPath,
removeElementKeyframe, removeElementKeyframe,
resolveAnimationTarget,
} from "@/animation"; } from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline";
import type { AnimationPath, AnimationValue } from "@/animation/types"; import type { AnimationPath } from "@/animation/types";
import type { ParamValue } from "@/params";
import type { SceneTracks, TimelineElement } from "@/timeline"; import type { SceneTracks, TimelineElement } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
function removeKeyframeAndPersist({ function removeKeyframeAndPersist({
element, element,
@@ -18,7 +19,7 @@ function removeKeyframeAndPersist({
element: TimelineElement; element: TimelineElement;
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
valueAtPlayhead: AnimationValue | null; valueAtPlayhead: ParamValue | null;
}): TimelineElement { }): TimelineElement {
const target = resolveAnimationTarget({ element, path: propertyPath }); const target = resolveAnimationTarget({ element, path: propertyPath });
if (!target) { if (!target) {
@@ -52,7 +53,7 @@ export class RemoveKeyframeCommand extends Command {
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
private readonly keyframeId: string; private readonly keyframeId: string;
private readonly valueAtPlayhead: AnimationValue | null; private readonly valueAtPlayhead: ParamValue | null;
constructor({ constructor({
trackId, trackId,
@@ -65,7 +66,7 @@ export class RemoveKeyframeCommand extends Command {
elementId: string; elementId: string;
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
valueAtPlayhead: AnimationValue | null; valueAtPlayhead: ParamValue | null;
}) { }) {
super(); super();
this.trackId = trackId; this.trackId = trackId;
@@ -1,9 +1,16 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation"; import { retimeElementKeyframe } from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline";
import type { AnimationPath } from "@/animation/types"; import type { AnimationPath } from "@/animation/types";
import type { SceneTracks } from "@/timeline"; import type { SceneTracks } from "@/timeline";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
export class RetimeKeyframeCommand extends Command { export class RetimeKeyframeCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
@@ -11,7 +18,7 @@ export class RetimeKeyframeCommand extends Command {
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
private readonly keyframeId: string; private readonly keyframeId: string;
private readonly nextTime: number; private readonly nextTime: MediaTime;
constructor({ constructor({
trackId, trackId,
@@ -24,7 +31,7 @@ export class RetimeKeyframeCommand extends Command {
elementId: string; elementId: string;
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
nextTime: number; nextTime: MediaTime;
}) { }) {
super(); super();
this.trackId = trackId; this.trackId = trackId;
@@ -47,7 +54,10 @@ export class RetimeKeyframeCommand extends Command {
return element; return element;
} }
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); const boundedTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: minMediaTime({ a: this.nextTime, b: element.duration }),
});
return { return {
...element, ...element,
animations: retimeElementKeyframe({ animations: retimeElementKeyframe({
@@ -1,10 +1,10 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { import {
resolveAnimationTarget,
updateScalarKeyframeCurve, updateScalarKeyframeCurve,
} from "@/animation"; } from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { import type {
AnimationPath, AnimationPath,
ScalarCurveKeyframePatch, ScalarCurveKeyframePatch,
@@ -2,13 +2,19 @@ import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { import {
buildEffectParamPath, buildEffectParamPath,
resolveAnimationTarget,
upsertPathKeyframe, upsertPathKeyframe,
} from "@/animation"; } from "@/animation";
import { updateElementInSceneTracks } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils"; import { isVisualElement } from "@/timeline/element-utils";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { AnimationInterpolation } from "@/animation/types"; import type { AnimationInterpolation } from "@/animation/types";
import type { SceneTracks } from "@/timeline"; import type { SceneTracks } from "@/timeline";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
export class UpsertEffectParamKeyframeCommand extends Command { export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
@@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
private readonly elementId: string; private readonly elementId: string;
private readonly effectId: string; private readonly effectId: string;
private readonly paramKey: string; private readonly paramKey: string;
private readonly time: number; private readonly time: MediaTime;
private readonly value: number | string | boolean; private readonly value: number | string | boolean;
private readonly interpolation: AnimationInterpolation | undefined; private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined; private readonly keyframeId: string | undefined;
@@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: string; elementId: string;
effectId: string; effectId: string;
paramKey: string; paramKey: string;
time: number; time: MediaTime;
value: number | string | boolean; value: number | string | boolean;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
@@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: this.elementId, elementId: this.elementId,
elementPredicate: isVisualElement, elementPredicate: isVisualElement,
update: (element) => { update: (element) => {
const boundedTime = Math.max(0, Math.min(this.time, element.duration)); const boundedTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: minMediaTime({ a: this.time, b: element.duration }),
});
const propertyPath = buildEffectParamPath({ const propertyPath = buildEffectParamPath({
effectId: this.effectId, effectId: this.effectId,
paramKey: this.paramKey, paramKey: this.paramKey,
@@ -81,8 +90,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
value: this.value, value: this.value,
interpolation: this.interpolation, interpolation: this.interpolation,
keyframeId: this.keyframeId, keyframeId: this.keyframeId,
kind: target.kind, channelLayout: target.channelLayout,
defaultInterpolation: target.defaultInterpolation,
coerceValue: target.coerceValue, coerceValue: target.coerceValue,
}); });
return { ...element, animations }; return { ...element, animations };
@@ -1,21 +1,28 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation"; import { upsertPathKeyframe } from "@/animation";
import { updateElementInSceneTracks } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline";
import type { SceneTracks } from "@/timeline"; import type { SceneTracks } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { import type {
AnimationPath, AnimationPath,
AnimationInterpolation, AnimationInterpolation,
AnimationValue,
} from "@/animation/types"; } from "@/animation/types";
import type { ParamValue } from "@/params";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
export class UpsertKeyframeCommand extends Command { export class UpsertKeyframeCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
private readonly time: number; private readonly time: MediaTime;
private readonly value: AnimationValue; private readonly value: ParamValue;
private readonly interpolation: AnimationInterpolation | undefined; private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined; private readonly keyframeId: string | undefined;
@@ -31,8 +38,8 @@ export class UpsertKeyframeCommand extends Command {
trackId: string; trackId: string;
elementId: string; elementId: string;
propertyPath: AnimationPath; propertyPath: AnimationPath;
time: number; time: MediaTime;
value: AnimationValue; value: ParamValue;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
}) { }) {
@@ -63,7 +70,10 @@ export class UpsertKeyframeCommand extends Command {
return element; return element;
} }
const boundedTime = Math.max(0, Math.min(this.time, element.duration)); const boundedTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: minMediaTime({ a: this.time, b: element.duration }),
});
return { return {
...element, ...element,
animations: upsertPathKeyframe({ animations: upsertPathKeyframe({
@@ -73,8 +83,7 @@ export class UpsertKeyframeCommand extends Command {
value: this.value, value: this.value,
interpolation: this.interpolation, interpolation: this.interpolation,
keyframeId: this.keyframeId, keyframeId: this.keyframeId,
kind: target.kind, channelLayout: target.channelLayout,
defaultInterpolation: target.defaultInterpolation,
coerceValue: target.coerceValue, coerceValue: target.coerceValue,
}), }),
}; };
@@ -1,22 +1,22 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { import {
getCustomMaskClosedStateAfterPointRemoval, getFreeformPathClosedStateAfterPointRemoval,
removeCustomMaskPoints, removeFreeformPathPoints,
} from "@/masks/custom-path"; } from "@/masks/freeform/path";
import type { CustomMask } from "@/masks/types"; import type { FreeformPathMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline";
function deletePointsFromCustomMask({ function deletePointsFromFreeformPathMask({
mask, mask,
pointIds, pointIds,
}: { }: {
mask: CustomMask; mask: FreeformPathMask;
pointIds: string[]; pointIds: string[];
}): CustomMask { }): FreeformPathMask {
const points = mask.params.path; const points = mask.params.path;
const nextPoints = removeCustomMaskPoints({ points, pointIds }); const nextPoints = removeFreeformPathPoints({ points, pointIds });
if (nextPoints.length === points.length) { if (nextPoints.length === points.length) {
return mask; return mask;
} }
@@ -26,7 +26,7 @@ function deletePointsFromCustomMask({
params: { params: {
...mask.params, ...mask.params,
path: nextPoints, path: nextPoints,
closed: getCustomMaskClosedStateAfterPointRemoval({ closed: getFreeformPathClosedStateAfterPointRemoval({
wasClosed: mask.params.closed, wasClosed: mask.params.closed,
remainingPointCount: nextPoints.length, remainingPointCount: nextPoints.length,
}), }),
@@ -46,11 +46,11 @@ function deletePointsFromElementMask({
const currentMasks = element.masks ?? []; const currentMasks = element.masks ?? [];
let didDeletePoints = false; let didDeletePoints = false;
const nextMasks = currentMasks.map((mask) => { const nextMasks = currentMasks.map((mask) => {
if (mask.id !== maskId || mask.type !== "custom") { if (mask.id !== maskId || mask.type !== "freeform") {
return mask; return mask;
} }
const nextMask = deletePointsFromCustomMask({ const nextMask = deletePointsFromFreeformPathMask({
mask, mask,
pointIds, pointIds,
}); });
@@ -64,7 +64,7 @@ function deletePointsFromElementMask({
}; };
} }
export class DeleteCustomMaskPointsCommand extends Command { export class DeleteFreeformPathMaskPointsCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
@@ -100,8 +100,9 @@ export class DeleteCustomMaskPointsCommand extends Command {
elementId: this.elementId, elementId: this.elementId,
elementPredicate: isMaskableElement, elementPredicate: isMaskableElement,
update: (element) => { update: (element) => {
if (!isMaskableElement(element)) return element;
const result = deletePointsFromElementMask({ const result = deletePointsFromElementMask({
element: element as MaskableElement, element,
maskId: this.maskId, maskId: this.maskId,
pointIds: this.pointIds, pointIds: this.pointIds,
}); });
@@ -1,4 +1,4 @@
export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points"; export { DeleteFreeformPathMaskPointsCommand } from "./delete-custom-mask-points";
export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point"; export { InsertFreeformPathMaskPointCommand } from "./insert-custom-mask-point";
export { RemoveMaskCommand } from "./remove-mask"; export { RemoveMaskCommand } from "./remove-mask";
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
@@ -1,23 +1,23 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom"; import { insertPointOnFreeformSegment } from "@/masks/freeform/definition";
import type { ElementBounds } from "@/preview/element-bounds"; import type { ElementBounds } from "@/preview/element-bounds";
import type { CustomMask } from "@/masks/types"; import type { FreeformPathMask } from "@/masks/types";
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
import type { MaskableElement, SceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline";
function insertPointIntoCustomMask({ function insertPointIntoFreeformPathMask({
mask, mask,
segmentIndex, segmentIndex,
canvasPoint, canvasPoint,
bounds, bounds,
}: { }: {
mask: CustomMask; mask: FreeformPathMask;
segmentIndex: number; segmentIndex: number;
canvasPoint: { x: number; y: number }; canvasPoint: { x: number; y: number };
bounds: ElementBounds; bounds: ElementBounds;
}): { mask: CustomMask; insertedPointId: string | null } { }): { mask: FreeformPathMask; insertedPointId: string | null } {
const result = insertPointOnCustomMaskSegment({ const result = insertPointOnFreeformSegment({
params: mask.params, params: mask.params,
segmentIndex, segmentIndex,
canvasPoint, canvasPoint,
@@ -61,11 +61,11 @@ function insertPointIntoElementMask({
let didInsertPoint = false; let didInsertPoint = false;
const nextMasks = currentMasks.map((mask) => { const nextMasks = currentMasks.map((mask) => {
if (mask.id !== maskId || mask.type !== "custom") { if (mask.id !== maskId || mask.type !== "freeform") {
return mask; return mask;
} }
const result = insertPointIntoCustomMask({ const result = insertPointIntoFreeformPathMask({
mask, mask,
segmentIndex, segmentIndex,
canvasPoint, canvasPoint,
@@ -85,7 +85,7 @@ function insertPointIntoElementMask({
}; };
} }
export class InsertCustomMaskPointCommand extends Command { export class InsertFreeformPathMaskPointCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
@@ -130,8 +130,9 @@ export class InsertCustomMaskPointCommand extends Command {
elementId: this.elementId, elementId: this.elementId,
elementPredicate: isMaskableElement, elementPredicate: isMaskableElement,
update: (element) => { update: (element) => {
if (!isMaskableElement(element)) return element;
const result = insertPointIntoElementMask({ const result = insertPointIntoElementMask({
element: element as MaskableElement, element,
maskId: this.maskId, maskId: this.maskId,
segmentIndex: this.segmentIndex, segmentIndex: this.segmentIndex,
canvasPoint: this.canvasPoint, canvasPoint: this.canvasPoint,
@@ -45,11 +45,13 @@ export class RemoveMaskCommand extends Command {
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,
elementPredicate: isMaskableElement, elementPredicate: isMaskableElement,
update: (element) => update: (element) => {
removeMaskFromElement({ if (!isMaskableElement(element)) return element;
element: element as MaskableElement, return removeMaskFromElement({
element,
maskId: this.maskId, maskId: this.maskId,
}), });
},
}); });
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
@@ -56,11 +56,13 @@ export class ToggleMaskInvertedCommand extends Command {
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,
elementPredicate: isMaskableElement, elementPredicate: isMaskableElement,
update: (element) => update: (element) => {
toggleMaskInvertedOnElement({ if (!isMaskableElement(element)) return element;
element: element as MaskableElement, return toggleMaskInvertedOnElement({
element,
maskId: this.maskId, maskId: this.maskId,
}), });
},
}); });
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
@@ -9,12 +9,18 @@ import { EditorCore } from "@/core";
import { isRetimableElement } from "@/timeline"; import { isRetimableElement } from "@/timeline";
import { splitAnimationsAtTime } from "@/animation"; import { splitAnimationsAtTime } from "@/animation";
import { getSourceSpanAtClipTime } from "@/retime"; import { getSourceSpanAtClipTime } from "@/retime";
import {
addMediaTime,
type MediaTime,
roundMediaTime,
subMediaTime,
} from "@/wasm";
export class SplitElementsCommand extends Command { export class SplitElementsCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = []; private rightSideElements: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[]; private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: number; private readonly splitTime: MediaTime;
private readonly retainSide: "both" | "left" | "right"; private readonly retainSide: "both" | "left" | "right";
constructor({ constructor({
@@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command {
retainSide = "both", retainSide = "both",
}: { }: {
elements: { trackId: string; elementId: string }[]; elements: { trackId: string; elementId: string }[];
splitTime: number; splitTime: MediaTime;
retainSide?: "both" | "left" | "right"; retainSide?: "both" | "left" | "right";
}) { }) {
super(); super();
@@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command {
return [element]; return [element];
} }
const relativeTime = this.splitTime - element.startTime; const relativeTime = subMediaTime({
a: this.splitTime,
b: element.startTime,
});
const leftVisibleDuration = relativeTime; const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime; const rightVisibleDuration = subMediaTime({
a: element.duration,
b: relativeTime,
});
const retimeRef = isRetimableElement(element) const retimeRef = isRetimableElement(element)
? element.retime ? element.retime
: undefined; : undefined;
const leftSourceSpan = getSourceSpanAtClipTime({ // Snap the source-side split point exactly once and derive the right
clipTime: leftVisibleDuration, // half from it. Independently rounding both spans (left and total)
retime: retimeRef, // would let a 1-tick rounding error desynchronise them, breaking the
// invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`.
// See the same discipline in `compute-resize.ts` (snap-once comment).
const leftSourceSpan = roundMediaTime({
time: getSourceSpanAtClipTime({
clipTime: leftVisibleDuration,
retime: retimeRef,
}),
}); });
const totalSourceSpan = getSourceSpanAtClipTime({ const totalSourceSpan = roundMediaTime({
clipTime: element.duration, time: getSourceSpanAtClipTime({
retime: retimeRef, clipTime: element.duration,
retime: retimeRef,
}),
});
const rightSourceSpan = subMediaTime({
a: totalSourceSpan,
b: leftSourceSpan,
}); });
const rightSourceSpan = totalSourceSpan - leftSourceSpan;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations, animations: element.animations,
splitTime: relativeTime, splitTime: relativeTime,
@@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command {
}); });
let splitResult: TimelineElement[]; let splitResult: TimelineElement[];
const leftTrimEnd = addMediaTime({
a: element.trimEnd,
b: rightSourceSpan,
});
const rightTrimStart = addMediaTime({
a: element.trimStart,
b: leftSourceSpan,
});
if (this.retainSide === "left") { if (this.retainSide === "left") {
splitResult = [ splitResult = [
{ {
...element, ...element,
duration: leftVisibleDuration, duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightSourceSpan, trimEnd: leftTrimEnd,
name: `${element.name} (left)`, name: `${element.name} (left)`,
animations: leftAnimations, animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}), ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command {
id: newId, id: newId,
startTime: this.splitTime, startTime: this.splitTime,
duration: rightVisibleDuration, duration: rightVisibleDuration,
trimStart: element.trimStart + leftSourceSpan, trimStart: rightTrimStart,
name: `${element.name} (right)`, name: `${element.name} (right)`,
animations: rightAnimations, animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}), ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
}, },
]; ];
} else { } else {
// "both" - split into two pieces
const secondElementId = generateUUID(); const secondElementId = generateUUID();
this.rightSideElements.push({ this.rightSideElements.push({
trackId: track.id, trackId: track.id,
@@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command {
{ {
...element, ...element,
duration: leftVisibleDuration, duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightSourceSpan, trimEnd: leftTrimEnd,
name: `${element.name} (left)`, name: `${element.name} (left)`,
animations: leftAnimations, animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}), ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command {
id: secondElementId, id: secondElementId,
startTime: this.splitTime, startTime: this.splitTime,
duration: rightVisibleDuration, duration: rightVisibleDuration,
trimStart: element.trimStart + leftSourceSpan, trimStart: rightTrimStart,
name: `${element.name} (right)`, name: `${element.name} (right)`,
animations: rightAnimations, animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}), ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@@ -11,14 +11,22 @@ export class AddTrackCommand extends Command {
private trackId: string; private trackId: string;
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
constructor( constructor({
private type: TrackType, type,
private index?: number, index,
) { }: {
type: TrackType;
index?: number;
}) {
super(); super();
this.type = type;
this.index = index;
this.trackId = generateUUID(); this.trackId = generateUUID();
} }
private type: TrackType;
private index?: number;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks; this.savedState = editor.scenes.getActiveScene().tracks;
@@ -3,13 +3,21 @@ import type { SceneTracks } from "@/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
export class TracksSnapshotCommand extends Command { export class TracksSnapshotCommand extends Command {
constructor( constructor({
private before: SceneTracks, before,
private after: SceneTracks, after,
) { }: {
before: SceneTracks;
after: SceneTracks;
}) {
super(); super();
this.before = before;
this.after = after;
} }
private before: SceneTracks;
private after: SceneTracks;
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
EditorCore.getInstance().timeline.updateTracks(this.after); EditorCore.getInstance().timeline.updateTracks(this.after);
return undefined; return undefined;
+19 -6
View File
@@ -1,15 +1,24 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm"; import {
formatTimecode,
type FrameRate,
type TimeCodeFormat,
} from "opencut-wasm";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import {
parseMediaTimecode,
snapSeekMediaTime,
type MediaTime,
} from "@/wasm";
interface EditableTimecodeProps { interface EditableTimecodeProps {
time: number; time: MediaTime;
duration: number; duration: MediaTime;
format?: TimeCodeFormat; format?: TimeCodeFormat;
fps: FrameRate; fps: FrameRate;
onTimeChange?: ({ time }: { time: number }) => void; onTimeChange?: ({ time }: { time: MediaTime }) => void;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
} }
@@ -46,7 +55,11 @@ export function EditableTimecode({
}; };
const applyEdit = () => { const applyEdit = () => {
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps }); const parsedTime = parseMediaTimecode({
timeCode: inputValue,
format,
fps,
});
if (parsedTime == null) { if (parsedTime == null) {
setHasError(true); setHasError(true);
@@ -54,7 +67,7 @@ export function EditableTimecode({
} }
const clampedTime = duration const clampedTime = duration
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime) ? snapSeekMediaTime({ time: parsedTime, duration, fps })
: parsedTime; : parsedTime;
onTimeChange?.({ time: clampedTime }); onTimeChange?.({ time: clampedTime });
@@ -93,7 +93,7 @@ interface AssetsPanelStore {
setMediaViewMode: (mode: MediaViewMode) => void; setMediaViewMode: (mode: MediaViewMode) => void;
mediaSortBy: MediaSortKey; mediaSortBy: MediaSortKey;
mediaSortOrder: MediaSortOrder; mediaSortOrder: MediaSortOrder;
setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void; setMediaSort: (args: { key: MediaSortKey; order: MediaSortOrder }) => void;
} }
export const useAssetsPanelStore = create<AssetsPanelStore>()( export const useAssetsPanelStore = create<AssetsPanelStore>()(
@@ -109,7 +109,7 @@ export const useAssetsPanelStore = create<AssetsPanelStore>()(
setMediaViewMode: (mode) => set({ mediaViewMode: mode }), setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
mediaSortBy: "name", mediaSortBy: "name",
mediaSortOrder: "asc", mediaSortOrder: "asc",
setMediaSort: (key, order) => setMediaSort: ({ key, order }) =>
set({ mediaSortBy: key, mediaSortOrder: order }), set({ mediaSortBy: key, mediaSortOrder: order }),
}), }),
{ {
@@ -1,240 +1,241 @@
"use client"; "use client";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react"; import { type ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { AspectRatio } from "@/components/ui/aspect-ratio"; import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import type { TimelineDragData } from "@/timeline/drag"; import type { TimelineDragData } from "@/timeline/drag";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import type { MediaTime } from "@/wasm";
export interface DraggableItemProps {
name: string; export interface DraggableItemProps {
preview: ReactNode; name: string;
dragData: TimelineDragData; preview: ReactNode;
onDragStart?: ({ e }: { e: React.DragEvent }) => void; dragData: TimelineDragData;
onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; onDragStart?: ({ e }: { e: React.DragEvent }) => void;
aspectRatio?: number; onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
className?: string; aspectRatio?: number;
containerClassName?: string; className?: string;
shouldShowPlusOnDrag?: boolean; containerClassName?: string;
shouldShowLabel?: boolean; shouldShowPlusOnDrag?: boolean;
isRounded?: boolean; shouldShowLabel?: boolean;
variant?: "card" | "compact"; isRounded?: boolean;
isDraggable?: boolean; variant?: "card" | "compact";
} isDraggable?: boolean;
}
export function DraggableItem({
name, export function DraggableItem({
preview, name,
dragData, preview,
onDragStart, dragData,
onAddToTimeline, onDragStart,
aspectRatio = 16 / 9, onAddToTimeline,
className = "", aspectRatio = 16 / 9,
containerClassName, className = "",
shouldShowPlusOnDrag = true, containerClassName,
shouldShowLabel = true, shouldShowPlusOnDrag = true,
isRounded = true, shouldShowLabel = true,
variant = "card", isRounded = true,
isDraggable = true, variant = "card",
}: DraggableItemProps) { isDraggable = true,
const [isDragging, setIsDragging] = useState(false); }: DraggableItemProps) {
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false);
const dragRef = useRef<HTMLDivElement>(null); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const editor = useEditor(); const dragRef = useRef<HTMLDivElement>(null);
const editor = useEditor();
const handleAddToTimeline = () => {
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); const handleAddToTimeline = () => {
}; onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
};
const emptyImg = new window.Image();
emptyImg.src = const emptyImg = new window.Image();
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; emptyImg.src =
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
useEffect(() => {
if (!isDragging) return; useEffect(() => {
if (!isDragging) return;
const handleDragOver = (e: DragEvent) => {
setDragPosition({ x: e.clientX, y: e.clientY }); const handleDragOver = (e: DragEvent) => {
}; setDragPosition({ x: e.clientX, y: e.clientY });
};
document.addEventListener("dragover", handleDragOver);
document.addEventListener("dragover", handleDragOver);
return () => {
document.removeEventListener("dragover", handleDragOver); return () => {
}; document.removeEventListener("dragover", handleDragOver);
}, [isDragging]); };
}, [isDragging]);
const handleDragStart = (event: React.DragEvent) => {
event.dataTransfer.setDragImage(emptyImg, 0, 0); const handleDragStart = (event: React.DragEvent) => {
event.dataTransfer.setDragImage(emptyImg, 0, 0);
editor.timeline.dragSource.begin({
dataTransfer: event.dataTransfer, editor.timeline.dragSource.begin({
dragData, dataTransfer: event.dataTransfer,
}); dragData,
});
setDragPosition({ x: event.clientX, y: event.clientY });
setIsDragging(true); setDragPosition({ x: event.clientX, y: event.clientY });
setIsDragging(true);
onDragStart?.({ e: event });
}; onDragStart?.({ e: event });
};
const handleDragEnd = () => {
setIsDragging(false); const handleDragEnd = () => {
editor.timeline.dragSource.end(); setIsDragging(false);
}; editor.timeline.dragSource.end();
};
return (
<> return (
{variant === "card" ? ( <>
<div {variant === "card" ? (
ref={dragRef} <div
className={cn("group relative", containerClassName ?? "w-28")} ref={dragRef}
> className={cn("group relative", containerClassName ?? "w-28")}
<div >
className={cn( <div
"relative flex h-auto w-full cursor-default flex-col gap-1 p-", className={cn(
className, "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
)} className,
> )}
<AspectRatio >
ratio={aspectRatio} <AspectRatio
className={cn( ratio={aspectRatio}
"bg-accent relative overflow-hidden", className={cn(
isRounded && "rounded-sm", "bg-accent relative overflow-hidden",
isDraggable && "[&::-webkit-drag-ghost]:opacity-0", isRounded && "rounded-sm",
)} isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
draggable={isDraggable} )}
onDragStart={isDraggable ? handleDragStart : undefined} draggable={isDraggable}
onDragEnd={isDraggable ? handleDragEnd : undefined} onDragStart={isDraggable ? handleDragStart : undefined}
> onDragEnd={isDraggable ? handleDragEnd : undefined}
{preview} >
{!isDragging && ( {preview}
<PlusButton {!isDragging && (
className="opacity-0 group-hover:opacity-100" <PlusButton
onClick={handleAddToTimeline} className="opacity-0 group-hover:opacity-100"
/> onClick={handleAddToTimeline}
)} />
</AspectRatio> )}
{shouldShowLabel && ( </AspectRatio>
<span {shouldShowLabel && (
className="text-muted-foreground w-full truncate text-left text-[0.7rem]" <span
title={name} className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
> title={name}
<span className="sr-only">{name}</span> >
<span aria-hidden="true"> <span className="sr-only">{name}</span>
{name.length > 8 <span aria-hidden="true">
? `${name.slice(0, 16)}...${name.slice(-3)}` {name.length > 8
: name} ? `${name.slice(0, 16)}...${name.slice(-3)}`
</span> : name}
</span> </span>
)} </span>
</div> )}
</div> </div>
) : ( </div>
<div ) : (
ref={dragRef} <div
className={cn("group relative w-full", containerClassName)} ref={dragRef}
> className={cn("group relative w-full", containerClassName)}
<button >
type="button" <button
className={cn( type="button"
"flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none", className={cn(
isDraggable && "[&::-webkit-drag-ghost]:opacity-0", "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
className, isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
)} className,
draggable={isDraggable} )}
onDragStart={isDraggable ? handleDragStart : undefined} draggable={isDraggable}
onDragEnd={isDraggable ? handleDragEnd : undefined} onDragStart={isDraggable ? handleDragStart : undefined}
> onDragEnd={isDraggable ? handleDragEnd : undefined}
<div className="size-6 shrink-0 overflow-hidden rounded-sm"> >
{preview} <div className="size-6 shrink-0 overflow-hidden rounded-sm">
</div> {preview}
<span className="w-full flex-1 truncate text-sm text-left"> </div>
{name} <span className="w-full flex-1 truncate text-sm text-left">
</span> {name}
</button> </span>
</div> </button>
)} </div>
)}
{isDraggable &&
isDragging && {isDraggable &&
typeof document !== "undefined" && isDragging &&
createPortal( typeof document !== "undefined" &&
<div createPortal(
className="pointer-events-none fixed z-9999" <div
style={{ className="pointer-events-none fixed z-9999"
left: dragPosition.x - 40, style={{
top: dragPosition.y - 40, left: dragPosition.x - 40,
}} top: dragPosition.y - 40,
> }}
<div className="w-[80px]"> >
<AspectRatio <div className="w-[80px]">
ratio={1} <AspectRatio
className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3" ratio={1}
> className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
<div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover"> >
{preview} <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
</div> {preview}
{shouldShowPlusOnDrag && ( </div>
<PlusButton {shouldShowPlusOnDrag && (
onClick={handleAddToTimeline} <PlusButton
tooltipText="Add to timeline or drag to position" onClick={handleAddToTimeline}
/> tooltipText="Add to timeline or drag to position"
)} />
</AspectRatio> )}
</div> </AspectRatio>
</div>, </div>
document.body, </div>,
)} document.body,
</> )}
); </>
} );
}
function PlusButton({
className, function PlusButton({
onClick, className,
tooltipText, onClick,
}: { tooltipText,
className?: string; }: {
onClick?: () => void; className?: string;
tooltipText?: string; onClick?: () => void;
}) { tooltipText?: string;
const button = ( }) {
<Button const button = (
size="icon" <Button
className={cn( size="icon"
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5", className={cn(
className, "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
)} className,
onClick={(e) => { )}
e.preventDefault(); onClick={(e) => {
e.stopPropagation(); e.preventDefault();
onClick?.(); e.stopPropagation();
}} onClick?.();
title={tooltipText} }}
> title={tooltipText}
<Plus /> >
</Button> <Plus />
); </Button>
);
if (tooltipText) {
return ( if (tooltipText) {
<Tooltip> return (
<TooltipTrigger asChild>{button}</TooltipTrigger> <Tooltip>
<TooltipContent> <TooltipTrigger asChild>{button}</TooltipTrigger>
<p>{tooltipText}</p> <TooltipContent>
</TooltipContent> <p>{tooltipText}</p>
</Tooltip> </TooltipContent>
); </Tooltip>
} );
}
return button;
} return button;
}
@@ -26,7 +26,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import { TICKS_PER_SECOND } from "@/wasm"; import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useFileUpload } from "@/media/use-file-upload"; import { useFileUpload } from "@/media/use-file-upload";
import { invokeAction } from "@/actions"; import { invokeAction } from "@/actions";
@@ -139,9 +139,12 @@ export function MediaView() {
const handleSort = ({ key }: { key: MediaSortKey }) => { const handleSort = ({ key }: { key: MediaSortKey }) => {
if (mediaSortBy === key) { if (mediaSortBy === key) {
setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc"); setMediaSort({
key,
order: mediaSortOrder === "asc" ? "desc" : "asc",
});
} else { } else {
setMediaSort(key, "asc"); setMediaSort({ key, order: "asc" });
} }
}; };
@@ -255,11 +258,11 @@ function MediaAssetDraggable({
startTime, startTime,
}: { }: {
asset: MediaAsset; asset: MediaAsset;
startTime: number; startTime: MediaTime;
}) => { }) => {
const duration = const duration =
asset.duration != null asset.duration != null
? Math.round(asset.duration * TICKS_PER_SECOND) ? mediaTimeFromSeconds({ seconds: asset.duration })
: DEFAULT_NEW_ELEMENT_DURATION; : DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({ const element = buildElementFromMedia({
mediaId: asset.id, mediaId: asset.id,
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useState } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { import {
Select, Select,
@@ -34,6 +34,10 @@ import type { TCanvasSize } from "@/project/types";
type SettingsView = "project-info" | "background"; type SettingsView = "project-info" | "background";
function isSettingsView(value: string): value is SettingsView {
return value === "project-info" || value === "background";
}
const PRESET_LABELS: Record<string, string> = { const PRESET_LABELS: Record<string, string> = {
"1:1": "1:1", "1:1": "1:1",
"16:9": "16:9", "16:9": "16:9",
@@ -73,23 +77,20 @@ function useCanvasDimensionDraft({
value: number; value: number;
onCommit: (value: number) => void; onCommit: (value: number) => void;
}) { }) {
const pendingValueRef = useRef(value); const [pendingValue, setPendingValue] = useState(value);
const syncedValueRef = useRef(value);
if (syncedValueRef.current !== value) {
syncedValueRef.current = value;
pendingValueRef.current = value;
}
return usePropertyDraft({ return usePropertyDraft({
displayValue: formatCanvasDimension({ value }), displayValue: formatCanvasDimension({ value }),
parse: (input) => parseCanvasDimension({ input }), parse: (input) => parseCanvasDimension({ input }),
onStartEditing: () => {
setPendingValue(value);
},
onPreview: (nextValue) => { onPreview: (nextValue) => {
pendingValueRef.current = nextValue; setPendingValue(nextValue);
}, },
onCommit: () => { onCommit: () => {
if (pendingValueRef.current !== value) { if (pendingValue !== value) {
onCommit(pendingValueRef.current); onCommit(pendingValue);
} }
}, },
}); });
@@ -212,7 +213,14 @@ export function SettingsView() {
contentClassName="px-0" contentClassName="px-0"
scrollClassName="pt-0" scrollClassName="pt-0"
actions={ actions={
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> <Tabs
value={view}
onValueChange={(value) => {
if (isSettingsView(value)) {
setView(value);
}
}}
>
<TabsList> <TabsList>
<TabsTrigger value="project-info">Project info</TabsTrigger> <TabsTrigger value="project-info">Project info</TabsTrigger>
<TabsTrigger value="background">Background</TabsTrigger> <TabsTrigger value="background">Background</TabsTrigger>
@@ -0,0 +1,154 @@
"use client";
import { resolveAnimationPathValueAtTime } from "@/animation";
import { Section, SectionContent, SectionFields } from "@/components/section";
import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
import { useKeyframedParamProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property";
import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field";
import type { ParamValue, ParamValues } from "@/params";
import {
getElementParams,
readElementParamValue,
writeElementParamValue,
type ElementParamDefinition,
} from "@/params/registry";
import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
export function ElementParamsTab({
element,
trackId,
paramKeys,
sectionKey,
}: {
element: TimelineElement;
trackId: string;
paramKeys?: readonly string[];
sectionKey: string;
}) {
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const params = getElementParams({ element }).filter(
(param) => !paramKeys || paramKeys.includes(param.key),
);
const baseValues = buildValues({ element, params });
return (
<Section sectionKey={`${element.id}:${sectionKey}`}>
<SectionContent className="pt-4">
<SectionFields>
{params
.filter((param) => isVisible({ param, values: baseValues }))
.map((param) => (
<ElementParamField
key={param.key}
element={element}
trackId={trackId}
param={param}
baseValue={baseValues[param.key] ?? param.default}
localTime={localTime}
isPlayheadWithinElementRange={isPlayheadWithinElementRange}
/>
))}
</SectionFields>
</SectionContent>
</Section>
);
}
function ElementParamField({
element,
trackId,
param,
baseValue,
localTime,
isPlayheadWithinElementRange,
}: {
element: TimelineElement;
trackId: string;
param: ElementParamDefinition;
baseValue: ParamValue;
localTime: MediaTime;
isPlayheadWithinElementRange: boolean;
}) {
const resolvedValue = resolveAnimationPathValueAtTime({
animations: element.animations,
propertyPath: param.key,
localTime,
fallbackValue: baseValue,
});
const animatedParam = useKeyframedParamProperty({
param,
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: param.key,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
buildBaseUpdates: ({ value }) =>
writeElementParamValue({ element, param, value }),
});
return (
<PropertyParamField
param={param}
value={resolvedValue}
onPreview={animatedParam.onPreview}
onCommit={animatedParam.onCommit}
keyframe={
param.keyframable === false
? undefined
: {
isActive: animatedParam.isKeyframedAtTime,
isDisabled: !isPlayheadWithinElementRange,
onToggle: animatedParam.toggleKeyframe,
}
}
/>
);
}
function buildValues({
element,
params,
}: {
element: TimelineElement;
params: readonly ElementParamDefinition[];
}): ParamValues {
const values: ParamValues = {};
for (const param of params) {
const value = readElementParamValue({ element, param });
if (value !== null) {
values[param.key] = value;
}
}
return values;
}
function isVisible({
param,
values,
}: {
param: ElementParamDefinition;
values: ParamValues;
}): boolean {
return (param.dependencies ?? []).every((dependency) =>
areParamValuesEqual({
left: values[dependency.param],
right: dependency.equals,
}),
);
}
function areParamValuesEqual({
left,
right,
}: {
left: ParamValue | undefined;
right: ParamValue;
}): boolean {
return left === right;
}
@@ -1,6 +1,10 @@
"use client"; "use client";
import type { ParamDefinition, NumberParamDefinition } from "@/params"; import type {
ParamDefinition,
NumberParamDefinition,
ParamValue,
} from "@/params";
import { import {
formatNumberForDisplay, formatNumberForDisplay,
getFractionDigitsForStep, getFractionDigitsForStep,
@@ -19,6 +23,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { usePropertyDraft } from "../hooks/use-property-draft"; import { usePropertyDraft } from "../hooks/use-property-draft";
import { KeyframeToggle } from "./keyframe-toggle"; import { KeyframeToggle } from "./keyframe-toggle";
import { Textarea } from "@/components/ui/textarea";
export function PropertyParamField({ export function PropertyParamField({
param, param,
@@ -28,8 +33,8 @@ export function PropertyParamField({
keyframe, keyframe,
}: { }: {
param: ParamDefinition; param: ParamDefinition;
value: number | string | boolean; value: ParamValue;
onPreview: (value: number | string | boolean) => void; onPreview: (value: ParamValue) => void;
onCommit: () => void; onCommit: () => void;
keyframe?: { keyframe?: {
isActive: boolean; isActive: boolean;
@@ -41,7 +46,7 @@ export function PropertyParamField({
<SectionField <SectionField
label={param.label} label={param.label}
beforeLabel={ beforeLabel={
keyframe ? ( keyframe && param.keyframable !== false ? (
<KeyframeToggle <KeyframeToggle
isActive={keyframe.isActive} isActive={keyframe.isActive}
isDisabled={keyframe.isDisabled} isDisabled={keyframe.isDisabled}
@@ -68,8 +73,8 @@ function ParamInput({
onCommit, onCommit,
}: { }: {
param: ParamDefinition; param: ParamDefinition;
value: number | string | boolean; value: ParamValue;
onPreview: (value: number | string | boolean) => void; onPreview: (value: ParamValue) => void;
onCommit: () => void; onCommit: () => void;
}) { }) {
if (param.type === "number") { if (param.type === "number") {
@@ -131,6 +136,27 @@ function ParamInput({
); );
} }
if (param.type === "text") {
return (
<Textarea
value={String(value)}
onChange={(event) => onPreview(event.currentTarget.value)}
onBlur={onCommit}
/>
);
}
if (param.type === "font") {
return (
<input
className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none"
value={String(value)}
onChange={(event) => onPreview(event.currentTarget.value)}
onBlur={onCommit}
/>
);
}
return null; return null;
} }
@@ -1,22 +1,25 @@
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { getElementLocalTime } from "@/animation"; import { getElementLocalTime } from "@/animation";
import { addMediaTime, mediaTime, type MediaTime } from "@/wasm";
export function useElementPlayhead({ export function useElementPlayhead({
startTime, startTime,
duration, duration,
}: { }: {
startTime: number; startTime: MediaTime;
duration: number; duration: MediaTime;
}) { }) {
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime()); const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
const localTime = getElementLocalTime({ const localTime = mediaTime({
timelineTime: playheadTime, ticks: getElementLocalTime({
elementStartTime: startTime, timelineTime: playheadTime,
elementDuration: duration, elementStartTime: startTime,
elementDuration: duration,
}),
}); });
const isPlayheadWithinElementRange = const isPlayheadWithinElementRange =
playheadTime >= startTime && playheadTime >= startTime &&
playheadTime <= startTime + duration; playheadTime <= addMediaTime({ a: startTime, b: duration });
return { localTime, isPlayheadWithinElementRange }; return { localTime, isPlayheadWithinElementRange };
} }
@@ -1,95 +0,0 @@
import { useEditor } from "@/editor/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
export function useKeyframedColorProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
resolvedColor,
buildBaseUpdates,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedColor: string;
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
}) {
const editor = useEditor();
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const onChange = ({ color }: { color: string }) => {
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertElementKeyframe({
animations,
propertyPath,
time: localTime,
value: color,
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
});
};
const onChangeEnd = () => editor.timeline.commitPreview();
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{ trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
],
});
};
return {
isKeyframedAtTime,
hasAnimatedKeyframes,
keyframeIdAtTime,
onChange,
onChangeEnd,
toggleKeyframe,
};
}
@@ -1,174 +0,0 @@
import { useEditor } from "@/editor/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedNumberProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
displayValue,
parse,
valueAtPlayhead,
step,
buildBaseUpdates,
buildAdditionalKeyframes,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
isPlayheadWithinElementRange: boolean;
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
step?: number;
buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
buildAdditionalKeyframes?: ({
value,
}: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>;
}) {
const editor = useEditor();
const snapValue = (value: number) =>
step != null ? snapToStep({ value, step }) : value;
const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ animations, propertyPath, time: localTime })
: null;
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
const isKeyframedAtTime = keyframeAtTime !== null;
const shouldUseAnimatedChannel =
hasAnimatedKeyframes && isPlayheadWithinElementRange;
const previewValue = ({ value }: { value: number }) => {
const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
const updatedAnimations = [
{ propertyPath, value: nextValue },
...additionalKeyframes,
].reduce(
(currentAnimations, keyframe) =>
upsertElementKeyframe({
animations: currentAnimations,
propertyPath: keyframe.propertyPath,
time: localTime,
value: keyframe.value,
}),
animations,
);
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: { animations: updatedAnimations },
},
],
});
return;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value: nextValue }),
},
],
});
};
const propertyDraft = usePropertyDraft({
displayValue,
parse: (input) => {
const parsedValue = parse(input);
return parsedValue === null ? null : snapValue(parsedValue);
},
onPreview: (value) => previewValue({ value }),
onCommit: () => editor.timeline.commitPreview(),
});
const toggleKeyframe = () => {
if (!isPlayheadWithinElementRange) {
return;
}
if (keyframeIdAtTime) {
editor.timeline.removeKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
keyframeId: keyframeIdAtTime,
},
],
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId,
propertyPath,
time: localTime,
value: snapValue(valueAtPlayhead),
},
],
});
};
const commitValue = ({ value }: { value: number }) => {
const nextValue = snapValue(value);
if (shouldUseAnimatedChannel) {
const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? [];
editor.timeline.upsertKeyframes({
keyframes: [
{ trackId, elementId, propertyPath, time: localTime, value: nextValue },
...additionalKeyframes.map((keyframe) => ({
trackId,
elementId,
propertyPath: keyframe.propertyPath,
time: localTime,
value: keyframe.value,
})),
],
});
return;
}
editor.timeline.updateElements({
updates: [
{
trackId,
elementId,
patch: buildBaseUpdates({ value: nextValue }),
},
],
});
};
return {
...propertyDraft,
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
toggleKeyframe,
commitValue,
};
}
@@ -3,18 +3,21 @@
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { import {
buildGraphicParamPath, buildGraphicParamPath,
coerceAnimationValueForParam,
getKeyframeAtTime, getKeyframeAtTime,
getParamDefaultInterpolation,
getParamValueKind,
hasKeyframesForPath, hasKeyframesForPath,
upsertPathKeyframe, upsertPathKeyframe,
} from "@/animation"; } from "@/animation";
import type { import type {
AnimationPath,
ElementAnimations, ElementAnimations,
} from "@/animation/types"; } from "@/animation/types";
import type { ParamDefinition } from "@/params"; import {
coerceParamValue,
getParamChannelLayout,
type ParamDefinition,
} from "@/params";
import type { TimelineElement } from "@/timeline"; import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
export interface KeyframedParamPropertyResult { export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean; hasAnimatedKeyframes: boolean;
@@ -30,6 +33,7 @@ export function useKeyframedParamProperty({
trackId, trackId,
elementId, elementId,
animations, animations,
propertyPath,
localTime, localTime,
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
resolvedValue, resolvedValue,
@@ -39,7 +43,8 @@ export function useKeyframedParamProperty({
trackId: string; trackId: string;
elementId: string; elementId: string;
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
localTime: number; propertyPath?: AnimationPath;
localTime: MediaTime;
isPlayheadWithinElementRange: boolean; isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean; resolvedValue: number | string | boolean;
buildBaseUpdates: ({ buildBaseUpdates: ({
@@ -49,15 +54,16 @@ export function useKeyframedParamProperty({
}) => Partial<TimelineElement>; }) => Partial<TimelineElement>;
}): KeyframedParamPropertyResult { }): KeyframedParamPropertyResult {
const editor = useEditor(); const editor = useEditor();
const propertyPath = buildGraphicParamPath({ paramKey: param.key }); const resolvedPropertyPath =
propertyPath ?? buildGraphicParamPath({ paramKey: param.key });
const hasAnimatedKeyframes = hasKeyframesForPath({ const hasAnimatedKeyframes = hasKeyframesForPath({
animations, animations,
propertyPath, propertyPath: resolvedPropertyPath,
}); });
const keyframeAtTime = isPlayheadWithinElementRange const keyframeAtTime = isPlayheadWithinElementRange
? getKeyframeAtTime({ ? getKeyframeAtTime({
animations, animations,
propertyPath, propertyPath: resolvedPropertyPath,
time: localTime, time: localTime,
}) })
: null; : null;
@@ -76,15 +82,12 @@ export function useKeyframedParamProperty({
updates: { updates: {
animations: upsertPathKeyframe({ animations: upsertPathKeyframe({
animations, animations,
propertyPath, propertyPath: resolvedPropertyPath,
time: localTime, time: localTime,
value, value,
kind: getParamValueKind({ param }), channelLayout: getParamChannelLayout({ param }),
defaultInterpolation: getParamDefaultInterpolation({
param,
}),
coerceValue: ({ value: nextValue }) => coerceValue: ({ value: nextValue }) =>
coerceAnimationValueForParam({ coerceParamValue({
param, param,
value: nextValue, value: nextValue,
}), }),
@@ -118,7 +121,7 @@ export function useKeyframedParamProperty({
{ {
trackId, trackId,
elementId, elementId,
propertyPath, propertyPath: resolvedPropertyPath,
keyframeId: keyframeIdAtTime, keyframeId: keyframeIdAtTime,
}, },
], ],
@@ -131,7 +134,7 @@ export function useKeyframedParamProperty({
{ {
trackId, trackId,
elementId, elementId,
propertyPath, propertyPath: resolvedPropertyPath,
time: localTime, time: localTime,
value: resolvedValue, value: resolvedValue,
}, },
@@ -1,4 +1,4 @@
import { useReducer, useRef } from "react"; import { useState } from "react";
import { evaluateMathExpression } from "@/utils/math"; import { evaluateMathExpression } from "@/utils/math";
function looksLikeExpression({ input }: { input: string }): boolean { function looksLikeExpression({ input }: { input: string }): boolean {
@@ -14,59 +14,56 @@ export function usePropertyDraft<T>({
parse, parse,
onPreview, onPreview,
onCommit, onCommit,
onStartEditing,
supportsExpressions = true, supportsExpressions = true,
}: { }: {
displayValue: string; displayValue: string;
parse: (input: string) => T | null; parse: (input: string) => T | null;
onPreview: (value: T) => void; onPreview: (value: T) => void;
onCommit: () => void; onCommit: () => void;
onStartEditing?: () => void;
supportsExpressions?: boolean; supportsExpressions?: boolean;
}) { }) {
const [, forceRender] = useReducer( const [isEditing, setIsEditing] = useState(false);
(renderVersion: number) => renderVersion + 1, const [draft, setDraft] = useState("");
0,
);
const isEditing = useRef(false);
const draft = useRef("");
return { return {
displayValue: isEditing.current ? draft.current : sourceDisplay, displayValue: isEditing ? draft : sourceDisplay,
scrubTo: (value: number) => { scrubTo: (value: number) => {
const parsed = parse(String(value)); const parsed = parse(String(value));
if (parsed !== null) onPreview(parsed); if (parsed !== null) onPreview(parsed);
}, },
commitScrub: onCommit, commitScrub: onCommit,
onFocus: () => { onFocus: () => {
isEditing.current = true; setIsEditing(true);
draft.current = sourceDisplay; setDraft(sourceDisplay);
forceRender(); onStartEditing?.();
}, },
onChange: ( onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => { ) => {
draft.current = event.target.value; const nextDraft = event.target.value;
forceRender(); setDraft(nextDraft);
const parsed = parse(event.target.value); const parsed = parse(nextDraft);
if (parsed !== null) { if (parsed !== null) {
onPreview(parsed); onPreview(parsed);
} }
}, },
onBlur: () => { onBlur: (
if ( event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
supportsExpressions && ) => {
looksLikeExpression({ input: draft.current }) const nextDraft = event.target.value;
) { if (supportsExpressions && looksLikeExpression({ input: nextDraft })) {
const evaluated = evaluateMathExpression({ input: draft.current }); const evaluated = evaluateMathExpression({ input: nextDraft });
if (evaluated !== null) { if (evaluated !== null) {
const parsed = parse(String(evaluated)); const parsed = parse(String(evaluated));
if (parsed !== null) onPreview(parsed); if (parsed !== null) onPreview(parsed);
} }
} }
onCommit(); onCommit();
isEditing.current = false; setIsEditing(false);
draft.current = ""; setDraft("");
forceRender();
}, },
}; };
} }
@@ -71,7 +71,12 @@ export function PropertiesPanel() {
<Button <Button
variant={tab.id === activeTab.id ? "secondary" : "ghost"} variant={tab.id === activeTab.id ? "secondary" : "ghost"}
size="icon" size="icon"
onClick={() => setActiveTab(element.type, tab.id)} onClick={() =>
setActiveTab({
elementType: element.type,
tabId: tab.id,
})
}
aria-label={tab.label} aria-label={tab.label}
className={cn( className={cn(
"shrink-0", "shrink-0",
@@ -22,16 +22,43 @@ import {
MagicWand05Icon, MagicWand05Icon,
DashboardSpeed02Icon, DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { TransformTab } from "@/rendering/components/transform-tab"; import { ElementParamsTab } from "./components/element-params-tab";
import { BlendingTab } from "@/rendering/components/blending-tab";
import { AudioTab } from "@/timeline/components/audio-tab";
import { TextTab } from "@/text/components/text-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab"; import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
import { MasksTab } from "@/masks/components/masks-tab"; import { MasksTab } from "@/masks/components/masks-tab";
import { SpeedTab } from "@/speed/components/speed-tab"; import { SpeedTab } from "@/speed/components/speed-tab";
import { GraphicTab } from "@/graphics/components/graphic-tab"; import { GraphicTab } from "@/graphics/components/graphic-tab";
import { OcShapesIcon } from "@/components/icons"; import { OcShapesIcon } from "@/components/icons";
const TRANSFORM_PARAM_KEYS = [
"transform.positionX",
"transform.positionY",
"transform.scaleX",
"transform.scaleY",
"transform.rotate",
] as const;
const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const;
const AUDIO_PARAM_KEYS = ["volume", "muted"] as const;
const TEXT_PARAM_KEYS = [
"content",
"fontFamily",
"fontSize",
"color",
"textAlign",
"fontWeight",
"fontStyle",
"textDecoration",
"letterSpacing",
"lineHeight",
"background.enabled",
"background.color",
"background.cornerRadius",
"background.paddingX",
"background.paddingY",
"background.offsetX",
"background.offsetY",
] as const;
export type TabContentProps = { export type TabContentProps = {
trackId: string; trackId: string;
}; };
@@ -58,7 +85,12 @@ function buildTransformTab({
label: "Transform", label: "Transform",
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
content: ({ trackId }) => ( content: ({ trackId }) => (
<TransformTab element={element} trackId={trackId} /> <ElementParamsTab
element={element}
trackId={trackId}
paramKeys={TRANSFORM_PARAM_KEYS}
sectionKey="transform"
/>
), ),
}; };
} }
@@ -73,7 +105,12 @@ function buildBlendingTab({
label: "Blending", label: "Blending",
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
content: ({ trackId }) => ( content: ({ trackId }) => (
<BlendingTab element={element} trackId={trackId} /> <ElementParamsTab
element={element}
trackId={trackId}
paramKeys={BLENDING_PARAM_KEYS}
sectionKey="blending"
/>
), ),
}; };
} }
@@ -87,7 +124,14 @@ function buildAudioTab({
id: "audio", id: "audio",
label: "Audio", label: "Audio",
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, content: ({ trackId }) => (
<ElementParamsTab
element={element}
trackId={trackId}
paramKeys={AUDIO_PARAM_KEYS}
sectionKey="audio"
/>
),
}; };
} }
@@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
id: "text", id: "text",
label: "Text", label: "Text",
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, content: ({ trackId }) => (
<ElementParamsTab
element={element}
trackId={trackId}
paramKeys={TEXT_PARAM_KEYS}
sectionKey="text"
/>
),
}; };
} }
@@ -2,17 +2,18 @@ import { create } from "zustand";
interface PropertiesState { interface PropertiesState {
activeTabPerType: Record<string, string>; activeTabPerType: Record<string, string>;
setActiveTab: (elementType: string, tabId: string) => void; setActiveTab: (args: { elementType: string; tabId: string }) => void;
isTransformScaleLocked: boolean; isTransformScaleLocked: boolean;
setTransformScaleLocked: (locked: boolean) => void; setTransformScaleLocked: (args: { locked: boolean }) => void;
} }
export const usePropertiesStore = create<PropertiesState>()((set) => ({ export const usePropertiesStore = create<PropertiesState>()((set) => ({
activeTabPerType: {}, activeTabPerType: {},
setActiveTab: (elementType, tabId) => setActiveTab: ({ elementType, tabId }) =>
set((state) => ({ set((state) => ({
activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId }, activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
})), })),
isTransformScaleLocked: false, isTransformScaleLocked: false,
setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }), setTransformScaleLocked: ({ locked }) =>
set({ isTransformScaleLocked: locked }),
})); }));
+1 -1
View File
@@ -39,7 +39,7 @@ export class EditorCore {
this.project = new ProjectManager(this); this.project = new ProjectManager(this);
this.media = new MediaManager(this); this.media = new MediaManager(this);
this.renderer = new RendererManager(this); this.renderer = new RendererManager(this);
this.save = new SaveManager(this); this.save = new SaveManager({ editor: this });
this.audio = new AudioManager(this); this.audio = new AudioManager(this);
this.selection = new SelectionManager(this); this.selection = new SelectionManager(this);
this.clipboard = new ClipboardManager(this); this.clipboard = new ClipboardManager(this);
@@ -6,6 +6,7 @@ import {
type CopyContext, type CopyContext,
type PasteContext, type PasteContext,
} from "@/clipboard"; } from "@/clipboard";
import type { MediaTime } from "@/wasm";
export class ClipboardManager { export class ClipboardManager {
private entry: ClipboardEntry | null = null; private entry: ClipboardEntry | null = null;
@@ -34,7 +35,7 @@ export class ClipboardManager {
return true; return true;
} }
paste({ time }: { time?: number } = {}): boolean { paste({ time }: { time?: MediaTime } = {}): boolean {
if (!this.entry) { if (!this.entry) {
return false; return false;
} }
@@ -64,7 +65,7 @@ export class ClipboardManager {
}; };
} }
private getPasteContext({ time }: { time?: number }): PasteContext { private getPasteContext({ time }: { time?: MediaTime }): PasteContext {
return { return {
editor: this.editor, editor: this.editor,
selectedElements: this.editor.selection.getSelectedElements(), selectedElements: this.editor.selection.getSelectedElements(),
+10 -2
View File
@@ -68,9 +68,17 @@ export class MediaManager {
const command = const command =
uniqueIds.length === 1 uniqueIds.length === 1
? new RemoveMediaAssetCommand(projectId, uniqueIds[0]) ? new RemoveMediaAssetCommand({
projectId,
assetId: uniqueIds[0],
})
: new BatchCommand( : new BatchCommand(
uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)), uniqueIds.map((id) =>
new RemoveMediaAssetCommand({
projectId,
assetId: id,
}),
),
); );
this.editor.command.execute({ command }); this.editor.command.execute({ command });
+47 -26
View File
@@ -1,20 +1,26 @@
import type { EditorCore } from "@/core"; import type { EditorCore } from "@/core";
import { TICKS_PER_SECOND } from "@/wasm"; import {
import { roundToFrame } from "opencut-wasm"; addMediaTime,
clampMediaTime,
type MediaTime,
mediaTimeFromSeconds,
roundFrameTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
export class PlaybackManager { export class PlaybackManager {
private isPlaying = false; private isPlaying = false;
private currentTime = 0; private currentTime: MediaTime = ZERO_MEDIA_TIME;
private volume = 1; private volume = 1;
private muted = false; private muted = false;
private previousVolume = 1; private previousVolume = 1;
private isScrubbing = false; private isScrubbing = false;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private updateListeners = new Set<(time: number) => void>(); private updateListeners = new Set<(time: MediaTime) => void>();
private seekListeners = new Set<(time: number) => void>(); private seekListeners = new Set<(time: MediaTime) => void>();
private playbackTimer: number | null = null; private playbackTimer: number | null = null;
private playbackStartWallTime = 0; private playbackStartWallTime = 0;
private playbackStartTime = 0; private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
private timelineScopeBound = false; private timelineScopeBound = false;
constructor(private editor: EditorCore) {} constructor(private editor: EditorCore) {}
@@ -40,7 +46,7 @@ export class PlaybackManager {
} }
if (this.currentTime >= maxTime) { if (this.currentTime >= maxTime) {
this.seek({ time: 0 }); this.seek({ time: ZERO_MEDIA_TIME });
} }
this.isPlaying = true; this.isPlaying = true;
@@ -62,7 +68,7 @@ export class PlaybackManager {
} }
} }
seek({ time }: { time: number }): void { seek({ time }: { time: MediaTime }): void {
this.currentTime = this.clampTimeToTimeline(time); this.currentTime = this.clampTimeToTimeline(time);
if (this.isPlaying) { if (this.isPlaying) {
this.playbackStartWallTime = performance.now(); this.playbackStartWallTime = performance.now();
@@ -109,7 +115,7 @@ export class PlaybackManager {
return this.isPlaying; return this.isPlaying;
} }
getCurrentTime(): number { getCurrentTime(): MediaTime {
return this.currentTime; return this.currentTime;
} }
@@ -135,12 +141,12 @@ export class PlaybackManager {
return () => this.listeners.delete(listener); return () => this.listeners.delete(listener);
} }
onUpdate(listener: (time: number) => void): () => void { onUpdate(listener: (time: MediaTime) => void): () => void {
this.updateListeners.add(listener); this.updateListeners.add(listener);
return () => this.updateListeners.delete(listener); return () => this.updateListeners.delete(listener);
} }
onSeek(listener: (time: number) => void): () => void { onSeek(listener: (time: MediaTime) => void): () => void {
this.seekListeners.add(listener); this.seekListeners.add(listener);
return () => this.seekListeners.delete(listener); return () => this.seekListeners.delete(listener);
} }
@@ -165,6 +171,7 @@ export class PlaybackManager {
if (timeChanged) { if (timeChanged) {
this.notifySeek(this.currentTime); this.notifySeek(this.currentTime);
this.dispatchSeekEvent(this.currentTime);
} }
} }
@@ -174,6 +181,18 @@ export class PlaybackManager {
}); });
} }
private notifyUpdate(time: MediaTime): void {
this.updateListeners.forEach((fn) => {
fn(time);
});
}
private notifySeek(time: MediaTime): void {
this.seekListeners.forEach((fn) => {
fn(time);
});
}
private startTimer(): void { private startTimer(): void {
if (this.playbackTimer) { if (this.playbackTimer) {
cancelAnimationFrame(this.playbackTimer); cancelAnimationFrame(this.playbackTimer);
@@ -197,40 +216,42 @@ export class PlaybackManager {
const fps = this.editor.project.getActive()?.settings.fps; const fps = this.editor.project.getActive()?.settings.fps;
const elapsedSeconds = const elapsedSeconds =
(performance.now() - this.playbackStartWallTime) / 1000; (performance.now() - this.playbackStartWallTime) / 1000;
const rawTime = const rawTime = addMediaTime({
this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND); a: this.playbackStartTime,
const newTime = fps b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) });
: rawTime; const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime;
const maxTime = this.editor.timeline.getTotalDuration(); const maxTime = this.editor.timeline.getTotalDuration();
if (newTime >= maxTime) { if (newTime >= maxTime) {
this.pause(); this.pause();
this.currentTime = maxTime; this.currentTime = maxTime;
this.notify(); this.notify();
this.notifySeek(maxTime); this.notifySeek(maxTime);
return; this.dispatchSeekEvent(maxTime);
return;
} }
this.currentTime = newTime; this.currentTime = newTime;
this.notifyUpdate(newTime); this.notifyUpdate(newTime);
this.dispatchUpdateEvent(newTime);
this.playbackTimer = requestAnimationFrame(this.updateTime); this.playbackTimer = requestAnimationFrame(this.updateTime);
}; };
private clampTimeToTimeline(time: number): number { private clampTimeToTimeline(time: MediaTime): MediaTime {
const maxTime = this.editor.timeline.getTotalDuration(); const maxTime = this.editor.timeline.getTotalDuration();
return Math.max(0, Math.min(maxTime, time)); return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime });
} }
private notifySeek(time: number): void { private dispatchSeekEvent(time: MediaTime): void {
for (const fn of this.seekListeners) { if (typeof window === "undefined") {
fn(time); return;
} }
} }
private notifyUpdate(time: number): void { private dispatchUpdateEvent(time: MediaTime): void {
for (const fn of this.updateListeners) { if (typeof window === "undefined") {
fn(time); return;
} }
} }
} }

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