mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43be7af1f3 | ||
|
|
94ae05fa6b | ||
|
|
67731d3071 | ||
|
|
26020a08d2 | ||
|
|
d1044796a6 | ||
|
|
0bdfef71a5 | ||
|
|
1928a99581 | ||
|
|
7b649de35d | ||
|
|
67e3cd0292 | ||
|
|
c92c7bf36a | ||
|
|
d631c920cb | ||
|
|
222ef055fc | ||
|
|
d7692b4c79 | ||
|
|
6ec818fc11 | ||
|
|
6daa69635b | ||
|
|
6b1f38a9a3 | ||
|
|
56ca09692a | ||
|
|
0b7597b31f | ||
|
|
e6c6193638 | ||
|
|
685d902740 | ||
|
|
eea6d43c82 |
@@ -168,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
|
||||
2. Make your changes
|
||||
3. Run the relevant checks for the area you touched:
|
||||
|
||||
- Web changes: from `apps/web`, run `bun run lint` and `bunx biome format --write .`
|
||||
- Web changes: from `apps/web`, run `bun run lint` and `bun run format`
|
||||
- Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet
|
||||
|
||||
4. Commit your changes with a descriptive message
|
||||
@@ -176,8 +176,8 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README
|
||||
|
||||
## Code Style
|
||||
|
||||
- We use Biome for code formatting and linting
|
||||
- Run `bunx biome format --write .` from the `apps/web` directory to format code
|
||||
- We use ESLint for linting and Prettier for formatting
|
||||
- Run `bun run format` from the `apps/web` directory to format code
|
||||
- Run `bun run lint` from the `apps/web` directory to check for linting issues
|
||||
- Follow the existing code patterns
|
||||
|
||||
|
||||
+342
-344
@@ -1,344 +1,342 @@
|
||||
---
|
||||
applyTo: "**/*.{ts,tsx,js,jsx}"
|
||||
---
|
||||
|
||||
# Project Context
|
||||
|
||||
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- Zero configuration required
|
||||
- Subsecond performance
|
||||
- Maximum type safety
|
||||
- AI-friendly code generation
|
||||
|
||||
## Before Writing Code
|
||||
|
||||
1. Analyze existing patterns in the codebase
|
||||
2. Consider edge cases and error scenarios
|
||||
3. Follow the rules below strictly
|
||||
4. Validate accessibility requirements
|
||||
|
||||
## Rules
|
||||
|
||||
### Accessibility (a11y)
|
||||
|
||||
- Don't use `accessKey` attribute on any HTML element.
|
||||
- Don't set `aria-hidden="true"` on focusable elements.
|
||||
- Don't add ARIA roles, states, and properties to elements that don't support them.
|
||||
- Don't use distracting elements like `<marquee>` or `<blink>`.
|
||||
- Only use the `scope` prop on `<th>` elements.
|
||||
- Don't assign non-interactive ARIA roles to interactive HTML elements.
|
||||
- Make sure label elements have text content and are associated with an input.
|
||||
- Don't assign interactive ARIA roles to non-interactive HTML elements.
|
||||
- Don't assign `tabIndex` to non-interactive HTML elements.
|
||||
- Don't use positive integers for `tabIndex` property.
|
||||
- Don't include "image", "picture", or "photo" in img alt prop.
|
||||
- Don't use explicit role property that's the same as the implicit/default role.
|
||||
- Make static elements with click handlers use a valid role attribute.
|
||||
- Always include a `title` element for SVG elements.
|
||||
- Give all elements requiring alt text meaningful information for screen readers.
|
||||
- Make sure anchors have content that's accessible to screen readers.
|
||||
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
|
||||
- Include all required ARIA attributes for elements with ARIA roles.
|
||||
- Make sure ARIA properties are valid for the element's supported roles.
|
||||
- Always include a `type` attribute for button elements.
|
||||
- Make elements with interactive roles and handlers focusable.
|
||||
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
|
||||
- Always include a `lang` attribute on the html element.
|
||||
- Always include a `title` attribute for iframe elements.
|
||||
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
|
||||
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
|
||||
- Include caption tracks for audio and video elements.
|
||||
- Use semantic elements instead of role attributes in JSX.
|
||||
- Make sure all anchors are valid and navigable.
|
||||
- Ensure all ARIA properties (`aria-*`) are valid.
|
||||
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
|
||||
- Use valid ARIA state and property values.
|
||||
- Use valid values for the `autocomplete` attribute on input elements.
|
||||
- Use correct ISO language/country codes for the `lang` attribute.
|
||||
|
||||
### Code Complexity and Quality
|
||||
|
||||
- Don't use consecutive spaces in regular expression literals.
|
||||
- Don't use the `arguments` object.
|
||||
- Don't use primitive type aliases or misleading types.
|
||||
- Don't use the comma operator.
|
||||
- Don't use empty type parameters in type aliases and interfaces.
|
||||
- Don't write functions that exceed a given Cognitive Complexity score.
|
||||
- Don't nest describe() blocks too deeply in test files.
|
||||
- Don't use unnecessary boolean casts.
|
||||
- Don't use unnecessary callbacks with flatMap.
|
||||
- Use for...of statements instead of Array.forEach.
|
||||
- Don't create classes that only have static members (like a static namespace).
|
||||
- Don't use this and super in static contexts.
|
||||
- Don't use unnecessary catch clauses.
|
||||
- Don't use unnecessary constructors.
|
||||
- Don't use unnecessary continue statements.
|
||||
- Don't export empty modules that don't change anything.
|
||||
- Don't use unnecessary escape sequences in regular expression literals.
|
||||
- Don't use unnecessary fragments.
|
||||
- Don't use unnecessary labels.
|
||||
- Don't use unnecessary nested block statements.
|
||||
- Don't rename imports, exports, and destructured assignments to the same name.
|
||||
- Don't use unnecessary string or template literal concatenation.
|
||||
- Don't use String.raw in template literals when there are no escape sequences.
|
||||
- Don't use useless case statements in switch statements.
|
||||
- Don't use ternary operators when simpler alternatives exist.
|
||||
- Don't use useless `this` aliasing.
|
||||
- Don't use any or unknown as type constraints.
|
||||
- Don't initialize variables to undefined.
|
||||
- Don't use the void operators (they're not familiar).
|
||||
- Use arrow functions instead of function expressions.
|
||||
- Use Date.now() to get milliseconds since the Unix Epoch.
|
||||
- Use .flatMap() instead of map().flat() when possible.
|
||||
- Use literal property access instead of computed property access.
|
||||
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
|
||||
- Use concise optional chaining instead of chained logical expressions.
|
||||
- Use regular expression literals instead of the RegExp constructor when possible.
|
||||
- Don't use number literal object member names that aren't base 10 or use underscore separators.
|
||||
- Remove redundant terms from logical expressions.
|
||||
- Use while loops instead of for loops when you don't need initializer and update expressions.
|
||||
- Don't pass children as props.
|
||||
- Don't reassign const variables.
|
||||
- Don't use constant expressions in conditions.
|
||||
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
|
||||
- Don't return a value from a constructor.
|
||||
- Don't use empty character classes in regular expression literals.
|
||||
- Don't use empty destructuring patterns.
|
||||
- Don't call global object properties as functions.
|
||||
- Don't declare functions and vars that are accessible outside their block.
|
||||
- Make sure builtins are correctly instantiated.
|
||||
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
|
||||
- Don't use variables and function parameters before they're declared.
|
||||
- Don't use 8 and 9 escape sequences in string literals.
|
||||
- Don't use literal numbers that lose precision.
|
||||
|
||||
### React and JSX Best Practices
|
||||
|
||||
- Don't use the return value of React.render.
|
||||
- Make sure all dependencies are correctly specified in React hooks.
|
||||
- Make sure all React hooks are called from the top level of component functions.
|
||||
- Don't forget key props in iterators and collection literals.
|
||||
- Don't destructure props inside JSX components in Solid projects.
|
||||
- Don't define React components inside other components.
|
||||
- Don't use event handlers on non-interactive elements.
|
||||
- Don't assign to React component props.
|
||||
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
|
||||
- Don't use dangerous JSX props.
|
||||
- Don't use Array index in keys.
|
||||
- Don't insert comments as text nodes.
|
||||
- Don't assign JSX properties multiple times.
|
||||
- Don't add extra closing tags for components without children.
|
||||
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
|
||||
- Watch out for possible "wrong" semicolons inside JSX elements.
|
||||
|
||||
### Correctness and Safety
|
||||
|
||||
- Don't assign a value to itself.
|
||||
- Don't return a value from a setter.
|
||||
- Don't compare expressions that modify string case with non-compliant values.
|
||||
- Don't use lexical declarations in switch clauses.
|
||||
- Don't use variables that haven't been declared in the document.
|
||||
- Don't write unreachable code.
|
||||
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
|
||||
- Don't use control flow statements in finally blocks.
|
||||
- Don't use optional chaining where undefined values aren't allowed.
|
||||
- Don't have unused function parameters.
|
||||
- Don't have unused imports.
|
||||
- Don't have unused labels.
|
||||
- Don't have unused private class members.
|
||||
- Don't have unused variables.
|
||||
- Make sure void (self-closing) elements don't have children.
|
||||
- Don't return a value from a function with the return type 'void'
|
||||
- Use isNaN() when checking for NaN.
|
||||
- Make sure "for" loop update clauses move the counter in the right direction.
|
||||
- Make sure typeof expressions are compared to valid values.
|
||||
- Make sure generator functions contain yield.
|
||||
- Don't use await inside loops.
|
||||
- Don't use bitwise operators.
|
||||
- Don't use expressions where the operation doesn't change the value.
|
||||
- Make sure Promise-like statements are handled appropriately.
|
||||
- Don't use **dirname and **filename in the global scope.
|
||||
- Prevent import cycles.
|
||||
- Don't use configured elements.
|
||||
- Don't hardcode sensitive data like API keys and tokens.
|
||||
- Don't let variable declarations shadow variables from outer scopes.
|
||||
- Don't use the TypeScript directive @ts-ignore.
|
||||
- Prevent duplicate polyfills from Polyfill.io.
|
||||
- Don't use useless backreferences in regular expressions that always match empty strings.
|
||||
- Don't use unnecessary escapes in string literals.
|
||||
- Don't use useless undefined.
|
||||
- Make sure getters and setters for the same property are next to each other in class and object definitions.
|
||||
- Make sure object literals are declared consistently (defaults to explicit definitions).
|
||||
- Use static Response methods instead of new Response() constructor when possible.
|
||||
- Make sure switch-case statements are exhaustive.
|
||||
- Make sure the `preconnect` attribute is used when using Google Fonts.
|
||||
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
|
||||
- Make sure iterable callbacks return consistent values.
|
||||
- Use `with { type: "json" }` for JSON module imports.
|
||||
- Use numeric separators in numeric literals.
|
||||
- Use object spread instead of `Object.assign()` when constructing new objects.
|
||||
- Always use the radix argument when using `parseInt()`.
|
||||
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
|
||||
- Include a description parameter for `Symbol()`.
|
||||
- Don't use spread (`...`) syntax on accumulators.
|
||||
- Don't use the `delete` operator.
|
||||
- Don't access namespace imports dynamically.
|
||||
- Don't use namespace imports.
|
||||
- Declare regex literals at the top level.
|
||||
- Don't use `target="_blank"` without `rel="noopener"`.
|
||||
|
||||
### TypeScript Best Practices
|
||||
|
||||
- Don't use TypeScript enums.
|
||||
- Don't export imported variables.
|
||||
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
|
||||
- Don't use TypeScript namespaces.
|
||||
- Don't use non-null assertions with the `!` postfix operator.
|
||||
- Don't use parameter properties in class constructors.
|
||||
- Don't use user-defined types.
|
||||
- Use `as const` instead of literal types and type annotations.
|
||||
- Use either `T[]` or `Array<T>` consistently.
|
||||
- Initialize each enum member value explicitly.
|
||||
- Use `export type` for types.
|
||||
- Use `import type` for types.
|
||||
- Make sure all enum members are literal values.
|
||||
- Don't use TypeScript const enum.
|
||||
- Don't declare empty interfaces.
|
||||
- Don't let variables evolve into any type through reassignments.
|
||||
- Don't use the any type.
|
||||
- Don't misuse the non-null assertion operator (!) in TypeScript files.
|
||||
- Don't use implicit any type on variable declarations.
|
||||
- Don't merge interfaces and classes unsafely.
|
||||
- Don't use overload signatures that aren't next to each other.
|
||||
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
|
||||
|
||||
### Style and Consistency
|
||||
|
||||
- Don't use global `eval()`.
|
||||
- Don't use callbacks in asynchronous tests and hooks.
|
||||
- Don't use negation in `if` statements that have `else` clauses.
|
||||
- Don't use nested ternary expressions.
|
||||
- Don't reassign function parameters.
|
||||
- This rule lets you specify global variable names you don't want to use in your application.
|
||||
- Don't use specified modules when loaded by import or require.
|
||||
- Don't use constants whose value is the upper-case version of their name.
|
||||
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
|
||||
- Don't use template literals if you don't need interpolation or special-character handling.
|
||||
- Don't use `else` blocks when the `if` block breaks early.
|
||||
- Don't use yoda expressions.
|
||||
- Don't use Array constructors.
|
||||
- Use `at()` instead of integer index access.
|
||||
- Follow curly brace conventions.
|
||||
- Use `else if` instead of nested `if` statements in `else` clauses.
|
||||
- Use single `if` statements instead of nested `if` clauses.
|
||||
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
|
||||
- Use consistent accessibility modifiers on class properties and methods.
|
||||
- Use `const` declarations for variables that are only assigned once.
|
||||
- Put default function parameters and optional function parameters last.
|
||||
- Include a `default` clause in switch statements.
|
||||
- Use the `**` operator instead of `Math.pow`.
|
||||
- Use `for-of` loops when you need the index to extract an item from the iterated array.
|
||||
- Use `node:assert/strict` over `node:assert`.
|
||||
- Use the `node:` protocol for Node.js builtin modules.
|
||||
- Use Number properties instead of global ones.
|
||||
- Use assignment operator shorthand where possible.
|
||||
- Use function types instead of object types with call signatures.
|
||||
- Use template literals over string concatenation.
|
||||
- Use `new` when throwing an error.
|
||||
- Don't throw non-Error values.
|
||||
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
|
||||
- Use standard constants instead of approximated literals.
|
||||
- Don't assign values in expressions.
|
||||
- Don't use async functions as Promise executors.
|
||||
- Don't reassign exceptions in catch clauses.
|
||||
- Don't reassign class members.
|
||||
- Don't compare against -0.
|
||||
- Don't use labeled statements that aren't loops.
|
||||
- Don't use void type outside of generic or return types.
|
||||
- Don't use console.
|
||||
- Don't use control characters and escape sequences that match control characters in regular expression literals.
|
||||
- Don't use debugger.
|
||||
- Don't assign directly to document.cookie.
|
||||
- Use `===` and `!==`.
|
||||
- Don't use duplicate case labels.
|
||||
- Don't use duplicate class members.
|
||||
- Don't use duplicate conditions in if-else-if chains.
|
||||
- Don't use two keys with the same name inside objects.
|
||||
- Don't use duplicate function parameter names.
|
||||
- Don't have duplicate hooks in describe blocks.
|
||||
- Don't use empty block statements and static blocks.
|
||||
- Don't let switch clauses fall through.
|
||||
- Don't reassign function declarations.
|
||||
- Don't allow assignments to native objects and read-only global variables.
|
||||
- Use Number.isFinite instead of global isFinite.
|
||||
- Use Number.isNaN instead of global isNaN.
|
||||
- Don't assign to imported bindings.
|
||||
- Don't use irregular whitespace characters.
|
||||
- Don't use labels that share a name with a variable.
|
||||
- Don't use characters made with multiple code points in character class syntax.
|
||||
- Make sure to use new and constructor properly.
|
||||
- Don't use shorthand assign when the variable appears on both sides.
|
||||
- Don't use octal escape sequences in string literals.
|
||||
- Don't use Object.prototype builtins directly.
|
||||
- Don't redeclare variables, functions, classes, and types in the same scope.
|
||||
- Don't have redundant "use strict".
|
||||
- Don't compare things where both sides are exactly the same.
|
||||
- Don't let identifiers shadow restricted names.
|
||||
- Don't use sparse arrays (arrays with holes).
|
||||
- Don't use template literal placeholder syntax in regular strings.
|
||||
- Don't use the then property.
|
||||
- Don't use unsafe negation.
|
||||
- Don't use var.
|
||||
- Don't use with statements in non-strict contexts.
|
||||
- Make sure async functions actually use await.
|
||||
- Make sure default clauses in switch statements come last.
|
||||
- Make sure to pass a message value when creating a built-in error.
|
||||
- Make sure get methods always return a value.
|
||||
- Use a recommended display strategy with Google Fonts.
|
||||
- Make sure for-in loops include an if statement.
|
||||
- Use Array.isArray() instead of instanceof Array.
|
||||
- Make sure to use the digits argument with Number#toFixed().
|
||||
- Make sure to use the "use strict" directive in script files.
|
||||
|
||||
### Next.js Specific Rules
|
||||
|
||||
- Don't use `<img>` elements in Next.js projects.
|
||||
- Don't use `<head>` elements in Next.js projects.
|
||||
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
|
||||
- Don't use the next/head module in pages/\_document.js on Next.js projects.
|
||||
|
||||
### Testing Best Practices
|
||||
|
||||
- Don't use export or module.exports in test files.
|
||||
- Don't use focused tests.
|
||||
- Make sure the assertion function, like expect, is placed inside an it() function call.
|
||||
- Don't use disabled tests.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- `npx ultracite init` - Initialize Ultracite in your project
|
||||
- `npx ultracite format` - Format and fix code automatically
|
||||
- `npx ultracite lint` - Check for issues without fixing
|
||||
|
||||
## Example: Error Handling
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Comprehensive error handling
|
||||
try {
|
||||
const result = await fetchData();
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error("API call failed:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
// ❌ Bad: Swallowing errors
|
||||
try {
|
||||
return await fetchData();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
```
|
||||
---
|
||||
applyTo: "**/*.{ts,tsx,js,jsx}"
|
||||
---
|
||||
|
||||
# Project Context
|
||||
|
||||
Strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects, enforced via ESLint (linting) and Prettier (formatting).
|
||||
|
||||
## Key Principles
|
||||
|
||||
- Maximum type safety
|
||||
- AI-friendly code generation
|
||||
|
||||
## Before Writing Code
|
||||
|
||||
1. Analyze existing patterns in the codebase
|
||||
2. Consider edge cases and error scenarios
|
||||
3. Follow the rules below strictly
|
||||
4. Validate accessibility requirements
|
||||
|
||||
## Rules
|
||||
|
||||
### Accessibility (a11y)
|
||||
|
||||
- Don't use `accessKey` attribute on any HTML element.
|
||||
- Don't set `aria-hidden="true"` on focusable elements.
|
||||
- Don't add ARIA roles, states, and properties to elements that don't support them.
|
||||
- Don't use distracting elements like `<marquee>` or `<blink>`.
|
||||
- Only use the `scope` prop on `<th>` elements.
|
||||
- Don't assign non-interactive ARIA roles to interactive HTML elements.
|
||||
- Make sure label elements have text content and are associated with an input.
|
||||
- Don't assign interactive ARIA roles to non-interactive HTML elements.
|
||||
- Don't assign `tabIndex` to non-interactive HTML elements.
|
||||
- Don't use positive integers for `tabIndex` property.
|
||||
- Don't include "image", "picture", or "photo" in img alt prop.
|
||||
- Don't use explicit role property that's the same as the implicit/default role.
|
||||
- Make static elements with click handlers use a valid role attribute.
|
||||
- Always include a `title` element for SVG elements.
|
||||
- Give all elements requiring alt text meaningful information for screen readers.
|
||||
- Make sure anchors have content that's accessible to screen readers.
|
||||
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
|
||||
- Include all required ARIA attributes for elements with ARIA roles.
|
||||
- Make sure ARIA properties are valid for the element's supported roles.
|
||||
- Always include a `type` attribute for button elements.
|
||||
- Make elements with interactive roles and handlers focusable.
|
||||
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
|
||||
- Always include a `lang` attribute on the html element.
|
||||
- Always include a `title` attribute for iframe elements.
|
||||
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
|
||||
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
|
||||
- Include caption tracks for audio and video elements.
|
||||
- Use semantic elements instead of role attributes in JSX.
|
||||
- Make sure all anchors are valid and navigable.
|
||||
- Ensure all ARIA properties (`aria-*`) are valid.
|
||||
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
|
||||
- Use valid ARIA state and property values.
|
||||
- Use valid values for the `autocomplete` attribute on input elements.
|
||||
- Use correct ISO language/country codes for the `lang` attribute.
|
||||
|
||||
### Code Complexity and Quality
|
||||
|
||||
- Don't use consecutive spaces in regular expression literals.
|
||||
- Don't use the `arguments` object.
|
||||
- Don't use primitive type aliases or misleading types.
|
||||
- Don't use the comma operator.
|
||||
- Don't use empty type parameters in type aliases and interfaces.
|
||||
- Don't write functions that exceed a given Cognitive Complexity score.
|
||||
- Don't nest describe() blocks too deeply in test files.
|
||||
- Don't use unnecessary boolean casts.
|
||||
- Don't use unnecessary callbacks with flatMap.
|
||||
- Use for...of statements instead of Array.forEach.
|
||||
- Don't create classes that only have static members (like a static namespace).
|
||||
- Don't use this and super in static contexts.
|
||||
- Don't use unnecessary catch clauses.
|
||||
- Don't use unnecessary constructors.
|
||||
- Don't use unnecessary continue statements.
|
||||
- Don't export empty modules that don't change anything.
|
||||
- Don't use unnecessary escape sequences in regular expression literals.
|
||||
- Don't use unnecessary fragments.
|
||||
- Don't use unnecessary labels.
|
||||
- Don't use unnecessary nested block statements.
|
||||
- Don't rename imports, exports, and destructured assignments to the same name.
|
||||
- Don't use unnecessary string or template literal concatenation.
|
||||
- Don't use String.raw in template literals when there are no escape sequences.
|
||||
- Don't use useless case statements in switch statements.
|
||||
- Don't use ternary operators when simpler alternatives exist.
|
||||
- Don't use useless `this` aliasing.
|
||||
- Don't use any or unknown as type constraints.
|
||||
- Don't initialize variables to undefined.
|
||||
- Don't use the void operators (they're not familiar).
|
||||
- Use arrow functions instead of function expressions.
|
||||
- Use Date.now() to get milliseconds since the Unix Epoch.
|
||||
- Use .flatMap() instead of map().flat() when possible.
|
||||
- Use literal property access instead of computed property access.
|
||||
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
|
||||
- Use concise optional chaining instead of chained logical expressions.
|
||||
- Use regular expression literals instead of the RegExp constructor when possible.
|
||||
- Don't use number literal object member names that aren't base 10 or use underscore separators.
|
||||
- Remove redundant terms from logical expressions.
|
||||
- Use while loops instead of for loops when you don't need initializer and update expressions.
|
||||
- Don't pass children as props.
|
||||
- Don't reassign const variables.
|
||||
- Don't use constant expressions in conditions.
|
||||
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
|
||||
- Don't return a value from a constructor.
|
||||
- Don't use empty character classes in regular expression literals.
|
||||
- Don't use empty destructuring patterns.
|
||||
- Don't call global object properties as functions.
|
||||
- Don't declare functions and vars that are accessible outside their block.
|
||||
- Make sure builtins are correctly instantiated.
|
||||
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
|
||||
- Don't use variables and function parameters before they're declared.
|
||||
- Don't use 8 and 9 escape sequences in string literals.
|
||||
- Don't use literal numbers that lose precision.
|
||||
|
||||
### React and JSX Best Practices
|
||||
|
||||
- Don't use the return value of React.render.
|
||||
- Make sure all dependencies are correctly specified in React hooks.
|
||||
- Make sure all React hooks are called from the top level of component functions.
|
||||
- Don't forget key props in iterators and collection literals.
|
||||
- Don't destructure props inside JSX components in Solid projects.
|
||||
- Don't define React components inside other components.
|
||||
- Don't use event handlers on non-interactive elements.
|
||||
- Don't assign to React component props.
|
||||
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
|
||||
- Don't use dangerous JSX props.
|
||||
- Don't use Array index in keys.
|
||||
- Don't insert comments as text nodes.
|
||||
- Don't assign JSX properties multiple times.
|
||||
- Don't add extra closing tags for components without children.
|
||||
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
|
||||
- Watch out for possible "wrong" semicolons inside JSX elements.
|
||||
|
||||
### Correctness and Safety
|
||||
|
||||
- Don't assign a value to itself.
|
||||
- Don't return a value from a setter.
|
||||
- Don't compare expressions that modify string case with non-compliant values.
|
||||
- Don't use lexical declarations in switch clauses.
|
||||
- Don't use variables that haven't been declared in the document.
|
||||
- Don't write unreachable code.
|
||||
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
|
||||
- Don't use control flow statements in finally blocks.
|
||||
- Don't use optional chaining where undefined values aren't allowed.
|
||||
- Don't have unused function parameters.
|
||||
- Don't have unused imports.
|
||||
- Don't have unused labels.
|
||||
- Don't have unused private class members.
|
||||
- Don't have unused variables.
|
||||
- Make sure void (self-closing) elements don't have children.
|
||||
- Don't return a value from a function with the return type 'void'
|
||||
- Use isNaN() when checking for NaN.
|
||||
- Make sure "for" loop update clauses move the counter in the right direction.
|
||||
- Make sure typeof expressions are compared to valid values.
|
||||
- Make sure generator functions contain yield.
|
||||
- Don't use await inside loops.
|
||||
- Don't use bitwise operators.
|
||||
- Don't use expressions where the operation doesn't change the value.
|
||||
- Make sure Promise-like statements are handled appropriately.
|
||||
- Don't use **dirname and **filename in the global scope.
|
||||
- Prevent import cycles.
|
||||
- Don't use configured elements.
|
||||
- Don't hardcode sensitive data like API keys and tokens.
|
||||
- Don't let variable declarations shadow variables from outer scopes.
|
||||
- Don't use the TypeScript directive @ts-ignore.
|
||||
- Prevent duplicate polyfills from Polyfill.io.
|
||||
- Don't use useless backreferences in regular expressions that always match empty strings.
|
||||
- Don't use unnecessary escapes in string literals.
|
||||
- Don't use useless undefined.
|
||||
- Make sure getters and setters for the same property are next to each other in class and object definitions.
|
||||
- Make sure object literals are declared consistently (defaults to explicit definitions).
|
||||
- Use static Response methods instead of new Response() constructor when possible.
|
||||
- Make sure switch-case statements are exhaustive.
|
||||
- Make sure the `preconnect` attribute is used when using Google Fonts.
|
||||
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
|
||||
- Make sure iterable callbacks return consistent values.
|
||||
- Use `with { type: "json" }` for JSON module imports.
|
||||
- Use numeric separators in numeric literals.
|
||||
- Use object spread instead of `Object.assign()` when constructing new objects.
|
||||
- Always use the radix argument when using `parseInt()`.
|
||||
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
|
||||
- Include a description parameter for `Symbol()`.
|
||||
- Don't use spread (`...`) syntax on accumulators.
|
||||
- Don't use the `delete` operator.
|
||||
- Don't access namespace imports dynamically.
|
||||
- Don't use namespace imports.
|
||||
- Declare regex literals at the top level.
|
||||
- Don't use `target="_blank"` without `rel="noopener"`.
|
||||
|
||||
### TypeScript Best Practices
|
||||
|
||||
- Don't use TypeScript enums.
|
||||
- Don't export imported variables.
|
||||
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
|
||||
- Don't use TypeScript namespaces.
|
||||
- Don't use non-null assertions with the `!` postfix operator.
|
||||
- Don't use parameter properties in class constructors.
|
||||
- Don't use user-defined types.
|
||||
- Use `as const` instead of literal types and type annotations.
|
||||
- Use either `T[]` or `Array<T>` consistently.
|
||||
- Initialize each enum member value explicitly.
|
||||
- Use `export type` for types.
|
||||
- Use `import type` for types.
|
||||
- Make sure all enum members are literal values.
|
||||
- Don't use TypeScript const enum.
|
||||
- Don't declare empty interfaces.
|
||||
- Don't let variables evolve into any type through reassignments.
|
||||
- Don't use the any type.
|
||||
- Don't misuse the non-null assertion operator (!) in TypeScript files.
|
||||
- Don't use implicit any type on variable declarations.
|
||||
- Don't merge interfaces and classes unsafely.
|
||||
- Don't use overload signatures that aren't next to each other.
|
||||
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
|
||||
|
||||
### Style and Consistency
|
||||
|
||||
- Don't use global `eval()`.
|
||||
- Don't use callbacks in asynchronous tests and hooks.
|
||||
- Don't use negation in `if` statements that have `else` clauses.
|
||||
- Don't use nested ternary expressions.
|
||||
- Don't reassign function parameters.
|
||||
- This rule lets you specify global variable names you don't want to use in your application.
|
||||
- Don't use specified modules when loaded by import or require.
|
||||
- Don't use constants whose value is the upper-case version of their name.
|
||||
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
|
||||
- Don't use template literals if you don't need interpolation or special-character handling.
|
||||
- Don't use `else` blocks when the `if` block breaks early.
|
||||
- Don't use yoda expressions.
|
||||
- Don't use Array constructors.
|
||||
- Use `at()` instead of integer index access.
|
||||
- Follow curly brace conventions.
|
||||
- Use `else if` instead of nested `if` statements in `else` clauses.
|
||||
- Use single `if` statements instead of nested `if` clauses.
|
||||
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
|
||||
- Use consistent accessibility modifiers on class properties and methods.
|
||||
- Use `const` declarations for variables that are only assigned once.
|
||||
- Put default function parameters and optional function parameters last.
|
||||
- Include a `default` clause in switch statements.
|
||||
- Use the `**` operator instead of `Math.pow`.
|
||||
- Use `for-of` loops when you need the index to extract an item from the iterated array.
|
||||
- Use `node:assert/strict` over `node:assert`.
|
||||
- Use the `node:` protocol for Node.js builtin modules.
|
||||
- Use Number properties instead of global ones.
|
||||
- Use assignment operator shorthand where possible.
|
||||
- Use function types instead of object types with call signatures.
|
||||
- Use template literals over string concatenation.
|
||||
- Use `new` when throwing an error.
|
||||
- Don't throw non-Error values.
|
||||
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
|
||||
- Use standard constants instead of approximated literals.
|
||||
- Don't assign values in expressions.
|
||||
- Don't use async functions as Promise executors.
|
||||
- Don't reassign exceptions in catch clauses.
|
||||
- Don't reassign class members.
|
||||
- Don't compare against -0.
|
||||
- Don't use labeled statements that aren't loops.
|
||||
- Don't use void type outside of generic or return types.
|
||||
- Don't use console.
|
||||
- Don't use control characters and escape sequences that match control characters in regular expression literals.
|
||||
- Don't use debugger.
|
||||
- Don't assign directly to document.cookie.
|
||||
- Use `===` and `!==`.
|
||||
- Don't use duplicate case labels.
|
||||
- Don't use duplicate class members.
|
||||
- Don't use duplicate conditions in if-else-if chains.
|
||||
- Don't use two keys with the same name inside objects.
|
||||
- Don't use duplicate function parameter names.
|
||||
- Don't have duplicate hooks in describe blocks.
|
||||
- Don't use empty block statements and static blocks.
|
||||
- Don't let switch clauses fall through.
|
||||
- Don't reassign function declarations.
|
||||
- Don't allow assignments to native objects and read-only global variables.
|
||||
- Use Number.isFinite instead of global isFinite.
|
||||
- Use Number.isNaN instead of global isNaN.
|
||||
- Don't assign to imported bindings.
|
||||
- Don't use irregular whitespace characters.
|
||||
- Don't use labels that share a name with a variable.
|
||||
- Don't use characters made with multiple code points in character class syntax.
|
||||
- Make sure to use new and constructor properly.
|
||||
- Don't use shorthand assign when the variable appears on both sides.
|
||||
- Don't use octal escape sequences in string literals.
|
||||
- Don't use Object.prototype builtins directly.
|
||||
- Don't redeclare variables, functions, classes, and types in the same scope.
|
||||
- Don't have redundant "use strict".
|
||||
- Don't compare things where both sides are exactly the same.
|
||||
- Don't let identifiers shadow restricted names.
|
||||
- Don't use sparse arrays (arrays with holes).
|
||||
- Don't use template literal placeholder syntax in regular strings.
|
||||
- Don't use the then property.
|
||||
- Don't use unsafe negation.
|
||||
- Don't use var.
|
||||
- Don't use with statements in non-strict contexts.
|
||||
- Make sure async functions actually use await.
|
||||
- Make sure default clauses in switch statements come last.
|
||||
- Make sure to pass a message value when creating a built-in error.
|
||||
- Make sure get methods always return a value.
|
||||
- Use a recommended display strategy with Google Fonts.
|
||||
- Make sure for-in loops include an if statement.
|
||||
- Use Array.isArray() instead of instanceof Array.
|
||||
- Make sure to use the digits argument with Number#toFixed().
|
||||
- Make sure to use the "use strict" directive in script files.
|
||||
|
||||
### Next.js Specific Rules
|
||||
|
||||
- Don't use `<img>` elements in Next.js projects.
|
||||
- Don't use `<head>` elements in Next.js projects.
|
||||
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
|
||||
- Don't use the next/head module in pages/\_document.js on Next.js projects.
|
||||
|
||||
### Testing Best Practices
|
||||
|
||||
- Don't use export or module.exports in test files.
|
||||
- Don't use focused tests.
|
||||
- Make sure the assertion function, like expect, is placed inside an it() function call.
|
||||
- Don't use disabled tests.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- `npx ultracite init` - Initialize Ultracite in your project
|
||||
- `npx ultracite format` - Format and fix code automatically
|
||||
- `npx ultracite lint` - Check for issues without fixing
|
||||
|
||||
## Example: Error Handling
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Comprehensive error handling
|
||||
try {
|
||||
const result = await fetchData();
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error("API call failed:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
// ❌ Bad: Swallowing errors
|
||||
try {
|
||||
return await fetchData();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
```
|
||||
|
||||
+1
-1
@@ -23,4 +23,4 @@ bun.lockb
|
||||
|
||||
target/
|
||||
|
||||
.cargo-tools/
|
||||
.cargo-tools/random_gibberish.txt
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.next
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
target
|
||||
rust/wasm/pkg
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"useTabs": true
|
||||
}
|
||||
Vendored
+2
-9
@@ -7,14 +7,7 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"emmet.showExpandedAbbreviation": "never",
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
"emmet.showExpandedAbbreviation": "never"
|
||||
}
|
||||
|
||||
@@ -21,17 +21,3 @@ Each app is a frontend that calls into Rust. Logic is never duplicated between a
|
||||
|
||||
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter:
|
||||
|
||||
```tsx
|
||||
// ❌ meaning depends on order; the shape can't evolve without touching every caller
|
||||
function formatTime(seconds: number, fps: number) { ... }
|
||||
|
||||
// ✅ each argument names itself; fields can be added, reordered, or made optional freely
|
||||
function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
|
||||
```
|
||||
|
||||
The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply.
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -3820,7 +3820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.2.9"
|
||||
version = "0.2.10"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"compositor",
|
||||
|
||||
@@ -1,167 +1,140 @@
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="left" width="120">
|
||||
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" />
|
||||
</td>
|
||||
<td align="right">
|
||||
<h1>OpenCut</h1>
|
||||
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||
|
||||
<a href="https://vercel.com/oss">
|
||||
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
|
||||
</a>
|
||||
|
||||
<a href="https://fal.ai">
|
||||
<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=" />
|
||||
</a>
|
||||
|
||||
## Why?
|
||||
|
||||
- **Privacy**: Your videos stay on your device
|
||||
- **Free features**: Most basic CapCut features are now paywalled
|
||||
- **Simple**: People want editors that are easy to use - CapCut proved that
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `apps/web/`: Next.js web application
|
||||
- `apps/desktop/`: Native desktop app built with GPUI (in progress)
|
||||
- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
|
||||
- `docs/`: Architecture and subsystem documentation
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/docs/installation)
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork and clone the repository
|
||||
|
||||
2. Copy the environment file:
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp apps/web/.env.example apps/web/.env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item apps/web/.env.example apps/web/.env.local
|
||||
```
|
||||
|
||||
3. Start the database and Redis:
|
||||
|
||||
```bash
|
||||
docker compose up -d db redis serverless-redis-http
|
||||
```
|
||||
|
||||
4. Install dependencies and start the dev server:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun dev:web
|
||||
```
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
|
||||
|
||||
### Desktop setup
|
||||
|
||||
Desktop is opt-in. If you're only working on the web app, skip this entirely.
|
||||
|
||||
If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies.
|
||||
|
||||
### Local WASM development
|
||||
|
||||
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
|
||||
|
||||
**Prerequisites** — install these once before anything else:
|
||||
|
||||
```bash
|
||||
# Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# build the WASM package
|
||||
cargo install wasm-pack
|
||||
|
||||
# reruns the build on file changes, used by bun dev:wasm
|
||||
cargo install cargo-watch
|
||||
```
|
||||
|
||||
1. Build the package once from the repo root:
|
||||
|
||||
```bash
|
||||
bun run build:wasm
|
||||
```
|
||||
|
||||
2. Register the generated package for linking:
|
||||
|
||||
```bash
|
||||
cd rust/wasm/pkg
|
||||
bun link
|
||||
```
|
||||
|
||||
3. Link `apps/web` to the local package:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun link opencut-wasm
|
||||
```
|
||||
|
||||
4. Rebuild on changes while you work:
|
||||
|
||||
```bash
|
||||
bun dev:wasm
|
||||
```
|
||||
|
||||
To switch `apps/web` back to the published package, run:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun add opencut-wasm
|
||||
```
|
||||
|
||||
### Self-Hosting with Docker
|
||||
|
||||
To run everything (including a production build of the app) in Docker:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app will be available at [http://localhost:3100](http://localhost:3100).
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
|
||||
|
||||
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
|
||||
|
||||
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
|
||||
|
||||
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
|
||||
|
||||
**Quick start for contributors:**
|
||||
|
||||
- Fork the repo and clone locally
|
||||
- Follow the setup instructions in CONTRIBUTING.md
|
||||
- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
|
||||
- Create a feature branch and submit a PR
|
||||
|
||||
## License
|
||||
|
||||
[MIT LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
| | |
|
||||
| --- | ---------------------------------------------------------------------- |
|
||||
| | OpenCutA free, open-source video editor for web, desktop, and mobile. |
|
||||
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||
|
||||
## Why?
|
||||
|
||||
- **Privacy**: Your videos stay on your device
|
||||
- **Free features**: Most basic CapCut features are now paywalled
|
||||
- **Simple**: People want editors that are easy to use - CapCut proved that
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `apps/web/`: Next.js web application
|
||||
- `apps/desktop/`: Native desktop app built with GPUI (in progress)
|
||||
- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
|
||||
- `docs/`: Architecture and subsystem documentation
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/docs/installation)
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork and clone the repository
|
||||
2. Copy the environment file:
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp apps/web/.env.example apps/web/.env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item apps/web/.env.example apps/web/.env.local
|
||||
```
|
||||
3. Start the database and Redis:
|
||||
```bash
|
||||
docker compose up -d db redis serverless-redis-http
|
||||
```
|
||||
4. Install dependencies and start the dev server:
|
||||
```bash
|
||||
bun install
|
||||
bun dev:web
|
||||
```
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
|
||||
|
||||
### Desktop setup
|
||||
|
||||
Desktop is opt-in. If you're only working on the web app, skip this entirely.
|
||||
|
||||
If you want to get ready for `apps/desktop`, see `[apps/desktop/README.md](apps/desktop/README.md)`. It's a two-step setup: Rust toolchain first, then desktop native dependencies.
|
||||
|
||||
### Local WASM development
|
||||
|
||||
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
|
||||
|
||||
**Prerequisites** — install these once before anything else:
|
||||
|
||||
```bash
|
||||
# Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# build the WASM package
|
||||
cargo install wasm-pack
|
||||
|
||||
# reruns the build on file changes, used by bun dev:wasm
|
||||
cargo install cargo-watch
|
||||
```
|
||||
|
||||
1. Build the package once from the repo root:
|
||||
```bash
|
||||
bun run build:wasm
|
||||
```
|
||||
2. Register the generated package for linking:
|
||||
```bash
|
||||
cd rust/wasm/pkg
|
||||
bun link
|
||||
```
|
||||
3. Link `apps/web` to the local package:
|
||||
```bash
|
||||
cd apps/web
|
||||
bun link opencut-wasm
|
||||
```
|
||||
4. Rebuild on changes while you work:
|
||||
```bash
|
||||
bun dev:wasm
|
||||
```
|
||||
|
||||
To switch `apps/web` back to the published package, run:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun add opencut-wasm
|
||||
```
|
||||
|
||||
### Self-Hosting with Docker
|
||||
|
||||
To run everything (including a production build of the app) in Docker:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app will be available at [http://localhost:3100](http://localhost:3100).
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
|
||||
|
||||
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
|
||||
|
||||
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
|
||||
|
||||
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
|
||||
|
||||
**Quick start for contributors:**
|
||||
|
||||
- Fork the repo and clone locally
|
||||
- Follow the setup instructions in CONTRIBUTING.md
|
||||
- Working on `apps/desktop`? See `[apps/desktop/README.md](apps/desktop/README.md)` for setup
|
||||
- Create a feature branch and submit a PR
|
||||
|
||||
## License
|
||||
|
||||
[MIT LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
Star History Chart
|
||||
@@ -9,9 +9,9 @@
|
||||
"start": "next start",
|
||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||
"lint": "biome check src/",
|
||||
"lint:fix": "biome check src/ --write",
|
||||
"format": "biome format src/ --write",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||
"format": "prettier src --write",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
|
||||
@@ -52,7 +52,7 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.2.9",
|
||||
"opencut-wasm": "^0.2.10",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
@@ -51,10 +51,10 @@ export function ShortcutsDialog({
|
||||
|
||||
const keyString = getKeybindingString(e);
|
||||
if (keyString) {
|
||||
const conflict = validateKeybinding(
|
||||
keyString,
|
||||
recordingShortcut.action,
|
||||
);
|
||||
const conflict = validateKeybinding({
|
||||
key: keyString,
|
||||
action: recordingShortcut.action,
|
||||
});
|
||||
if (conflict) {
|
||||
toast.error(
|
||||
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
|
||||
@@ -68,7 +68,10 @@ export function ShortcutsDialog({
|
||||
removeKeybinding(key);
|
||||
}
|
||||
|
||||
updateKeybinding(keyString, recordingShortcut.action);
|
||||
updateKeybinding({
|
||||
key: keyString,
|
||||
action: recordingShortcut.action,
|
||||
});
|
||||
|
||||
setIsRecording(false);
|
||||
setRecordingShortcut(null);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
KeybindingConfig,
|
||||
ShortcutKey,
|
||||
} from "@/actions/keybinding";
|
||||
import type { ShortcutKey } from "@/actions/keybinding";
|
||||
import type { TActionWithOptionalArgs } from "./types";
|
||||
|
||||
export type TActionCategory =
|
||||
@@ -155,33 +152,36 @@ export const ACTIONS = {
|
||||
|
||||
export type TAction = keyof typeof ACTIONS;
|
||||
|
||||
const ACTION_DEFAULT_SHORTCUTS = {
|
||||
"toggle-play": ["space", "k"],
|
||||
"seek-forward": ["l"],
|
||||
"seek-backward": ["j"],
|
||||
"frame-step-forward": ["right"],
|
||||
"frame-step-backward": ["left"],
|
||||
"jump-forward": ["shift+right"],
|
||||
"jump-backward": ["shift+left"],
|
||||
"goto-start": ["home", "enter"],
|
||||
"goto-end": ["end"],
|
||||
split: ["s"],
|
||||
"split-left": ["q"],
|
||||
"split-right": ["w"],
|
||||
"delete-selected": ["backspace", "delete"],
|
||||
"copy-selected": ["ctrl+c"],
|
||||
"paste-copied": ["ctrl+v"],
|
||||
"toggle-snapping": ["n"],
|
||||
"select-all": ["ctrl+a"],
|
||||
"cancel-interaction": ["escape"],
|
||||
"duplicate-selected": ["ctrl+d"],
|
||||
undo: ["ctrl+z"],
|
||||
redo: ["ctrl+shift+z", "ctrl+y"],
|
||||
} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>;
|
||||
const ACTION_DEFAULT_SHORTCUTS = [
|
||||
["toggle-play", ["space", "k"]],
|
||||
["seek-forward", ["l"]],
|
||||
["seek-backward", ["j"]],
|
||||
["frame-step-forward", ["right"]],
|
||||
["frame-step-backward", ["left"]],
|
||||
["jump-forward", ["shift+right"]],
|
||||
["jump-backward", ["shift+left"]],
|
||||
["goto-start", ["home", "enter"]],
|
||||
["goto-end", ["end"]],
|
||||
["split", ["s"]],
|
||||
["split-left", ["q"]],
|
||||
["split-right", ["w"]],
|
||||
["delete-selected", ["backspace", "delete"]],
|
||||
["copy-selected", ["ctrl+c"]],
|
||||
["paste-copied", ["ctrl+v"]],
|
||||
["toggle-snapping", ["n"]],
|
||||
["select-all", ["ctrl+a"]],
|
||||
["cancel-interaction", ["escape"]],
|
||||
["duplicate-selected", ["ctrl+d"]],
|
||||
["undo", ["ctrl+z"]],
|
||||
["redo", ["ctrl+shift+z", "ctrl+y"]],
|
||||
] as const satisfies ReadonlyArray<
|
||||
readonly [TActionWithOptionalArgs, readonly ShortcutKey[]]
|
||||
>;
|
||||
|
||||
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial<
|
||||
Record<TAction, readonly ShortcutKey[]>
|
||||
> = ACTION_DEFAULT_SHORTCUTS;
|
||||
const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map<
|
||||
TAction,
|
||||
readonly ShortcutKey[]
|
||||
>(ACTION_DEFAULT_SHORTCUTS);
|
||||
|
||||
export function getActionDefinition({
|
||||
action,
|
||||
@@ -190,18 +190,19 @@ export function getActionDefinition({
|
||||
}): TActionDefinition {
|
||||
return {
|
||||
...ACTIONS[action],
|
||||
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action],
|
||||
defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultShortcuts(): KeybindingConfig {
|
||||
const shortcuts: KeybindingConfig = {};
|
||||
export function getDefaultShortcuts(): Map<
|
||||
ShortcutKey,
|
||||
TActionWithOptionalArgs
|
||||
> {
|
||||
const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>();
|
||||
|
||||
for (const [action, defaultShortcuts] of Object.entries(
|
||||
ACTION_DEFAULT_SHORTCUTS,
|
||||
) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) {
|
||||
for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) {
|
||||
for (const shortcut of defaultShortcuts) {
|
||||
shortcuts[shortcut] = action;
|
||||
shortcuts.set(shortcut, action);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +1,43 @@
|
||||
import type { TActionWithOptionalArgs } from "./types";
|
||||
|
||||
/**
|
||||
* Alt is also regarded as macOS OPTION (⌥) key
|
||||
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
|
||||
*/
|
||||
export type ModifierKeys =
|
||||
| "ctrl"
|
||||
| "alt"
|
||||
| "shift"
|
||||
| "ctrl+shift"
|
||||
| "alt+shift"
|
||||
| "ctrl+alt"
|
||||
| "ctrl+alt+shift";
|
||||
|
||||
export type Key =
|
||||
| "a"
|
||||
| "b"
|
||||
| "c"
|
||||
| "d"
|
||||
| "e"
|
||||
| "f"
|
||||
| "g"
|
||||
| "h"
|
||||
| "i"
|
||||
| "j"
|
||||
| "k"
|
||||
| "l"
|
||||
| "m"
|
||||
| "n"
|
||||
| "o"
|
||||
| "p"
|
||||
| "q"
|
||||
| "r"
|
||||
| "s"
|
||||
| "t"
|
||||
| "u"
|
||||
| "v"
|
||||
| "w"
|
||||
| "x"
|
||||
| "y"
|
||||
| "z"
|
||||
| "0"
|
||||
| "1"
|
||||
| "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right"
|
||||
| "/"
|
||||
| "?"
|
||||
| "."
|
||||
| "enter"
|
||||
| "tab"
|
||||
| "space"
|
||||
| "escape"
|
||||
| "esc"
|
||||
| "backspace"
|
||||
| "delete"
|
||||
| "home"
|
||||
| "end";
|
||||
/* eslint-enable */
|
||||
|
||||
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
|
||||
// Singular keybindings (these will be disabled when an input-ish area has been focused)
|
||||
export type SingleCharacterShortcutKey = `${Key}`;
|
||||
|
||||
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
|
||||
|
||||
export type KeybindingConfig = {
|
||||
[key in ShortcutKey]?: TActionWithOptionalArgs;
|
||||
};
|
||||
import type { TActionWithOptionalArgs } from "./types";
|
||||
|
||||
/**
|
||||
* Alt is also regarded as macOS OPTION (⌥) key
|
||||
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
|
||||
*/
|
||||
export type ModifierKeys =
|
||||
| "ctrl"
|
||||
| "alt"
|
||||
| "shift"
|
||||
| "ctrl+shift"
|
||||
| "alt+shift"
|
||||
| "ctrl+alt"
|
||||
| "ctrl+alt+shift";
|
||||
|
||||
const KEYS = [
|
||||
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
|
||||
"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
|
||||
"u", "v", "w", "x", "y", "z",
|
||||
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
||||
"up", "down", "left", "right",
|
||||
"/", "?", ".",
|
||||
"enter", "tab", "space", "escape", "esc",
|
||||
"backspace", "delete", "home", "end",
|
||||
] as const;
|
||||
|
||||
export type Key = (typeof KEYS)[number];
|
||||
|
||||
const KEY_SET: ReadonlySet<string> = new Set(KEYS);
|
||||
|
||||
export function isKey(value: string): value is Key {
|
||||
return KEY_SET.has(value);
|
||||
}
|
||||
|
||||
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
|
||||
// Singular keybindings (these will be disabled when an input-ish area has been focused)
|
||||
export type SingleCharacterShortcutKey = `${Key}`;
|
||||
|
||||
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
|
||||
|
||||
export type KeybindingConfig = {
|
||||
[key in ShortcutKey]?: TActionWithOptionalArgs;
|
||||
};
|
||||
|
||||
@@ -6,11 +6,15 @@ import type { TActionWithOptionalArgs } from "@/actions";
|
||||
import { getDefaultShortcuts } from "@/actions";
|
||||
import { isTypableDOMElement } from "@/utils/browser";
|
||||
import { isAppleDevice } from "@/utils/platform";
|
||||
import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
|
||||
import type {
|
||||
Key,
|
||||
KeybindingConfig,
|
||||
ModifierKeys,
|
||||
ShortcutKey,
|
||||
} from "@/actions/keybinding";
|
||||
import { isKey } from "@/actions/keybinding";
|
||||
import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations";
|
||||
|
||||
const defaultKeybindings: KeybindingConfig = getDefaultShortcuts();
|
||||
|
||||
export interface KeybindingConflict {
|
||||
key: ShortcutKey;
|
||||
existingAction: TActionWithOptionalArgs;
|
||||
@@ -18,38 +22,57 @@ export interface KeybindingConflict {
|
||||
}
|
||||
|
||||
interface KeybindingsState {
|
||||
keybindings: KeybindingConfig;
|
||||
keybindings: Map<ShortcutKey, TActionWithOptionalArgs>;
|
||||
isCustomized: boolean;
|
||||
overlayDepth: number;
|
||||
openOverlayIds: string[];
|
||||
isLoadingProject: boolean;
|
||||
isRecording: boolean;
|
||||
|
||||
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void;
|
||||
updateKeybinding: (params: {
|
||||
key: ShortcutKey;
|
||||
action: TActionWithOptionalArgs;
|
||||
}) => void;
|
||||
removeKeybinding: (key: ShortcutKey) => void;
|
||||
resetToDefaults: () => void;
|
||||
importKeybindings: (config: KeybindingConfig) => void;
|
||||
exportKeybindings: () => KeybindingConfig;
|
||||
exportKeybindings: () => Record<string, TActionWithOptionalArgs>;
|
||||
openOverlay: (overlayId: string) => void;
|
||||
closeOverlay: (overlayId: string) => void;
|
||||
setLoadingProject: (loading: boolean) => void;
|
||||
setIsRecording: (isRecording: boolean) => void;
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: TActionWithOptionalArgs,
|
||||
) => KeybindingConflict | null;
|
||||
validateKeybinding: (params: {
|
||||
key: ShortcutKey;
|
||||
action: TActionWithOptionalArgs;
|
||||
}) => KeybindingConflict | null;
|
||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[];
|
||||
getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null;
|
||||
}
|
||||
|
||||
type PersistedState = {
|
||||
keybindings: Record<string, TActionWithOptionalArgs>;
|
||||
isCustomized: boolean;
|
||||
};
|
||||
|
||||
function isDOMElement(element: EventTarget | null): element is HTMLElement {
|
||||
return element instanceof HTMLElement;
|
||||
}
|
||||
|
||||
function isPersistedState(value: unknown): value is PersistedState {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("keybindings" in value) || !("isCustomized" in value)) return false;
|
||||
const { keybindings, isCustomized } = value;
|
||||
return (
|
||||
typeof keybindings === "object" &&
|
||||
keybindings !== null &&
|
||||
typeof isCustomized === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
keybindings: getDefaultShortcuts(),
|
||||
isCustomized: false,
|
||||
overlayDepth: 0,
|
||||
openOverlayIds: [],
|
||||
@@ -61,10 +84,9 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
const openOverlayIds = s.openOverlayIds.includes(overlayId)
|
||||
? s.openOverlayIds
|
||||
: [...s.openOverlayIds, overlayId];
|
||||
const nextOverlayDepth = openOverlayIds.length;
|
||||
return {
|
||||
openOverlayIds,
|
||||
overlayDepth: nextOverlayDepth,
|
||||
overlayDepth: openOverlayIds.length,
|
||||
};
|
||||
}),
|
||||
closeOverlay: (overlayId) =>
|
||||
@@ -72,35 +94,32 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
const openOverlayIds = s.openOverlayIds.filter(
|
||||
(id) => id !== overlayId,
|
||||
);
|
||||
const nextOverlayDepth = openOverlayIds.length;
|
||||
return {
|
||||
openOverlayIds,
|
||||
overlayDepth: nextOverlayDepth,
|
||||
overlayDepth: openOverlayIds.length,
|
||||
};
|
||||
}),
|
||||
setLoadingProject: (loading) => {
|
||||
set({ isLoadingProject: loading });
|
||||
},
|
||||
|
||||
updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => {
|
||||
updateKeybinding: ({ key, action }) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
newKeybindings[key] = action;
|
||||
|
||||
const next = new Map(state.keybindings);
|
||||
next.set(key, action);
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
keybindings: next,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeKeybinding: (key: ShortcutKey) => {
|
||||
removeKeybinding: (key) => {
|
||||
set((state) => {
|
||||
const newKeybindings = { ...state.keybindings };
|
||||
delete newKeybindings[key];
|
||||
|
||||
const next = new Map(state.keybindings);
|
||||
next.delete(key);
|
||||
return {
|
||||
keybindings: newKeybindings,
|
||||
keybindings: next,
|
||||
isCustomized: true,
|
||||
};
|
||||
});
|
||||
@@ -108,34 +127,35 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
|
||||
resetToDefaults: () => {
|
||||
set({
|
||||
keybindings: { ...defaultKeybindings },
|
||||
keybindings: getDefaultShortcuts(),
|
||||
isCustomized: false,
|
||||
});
|
||||
},
|
||||
|
||||
importKeybindings: (config: KeybindingConfig) => {
|
||||
for (const [key] of Object.entries(config)) {
|
||||
importKeybindings: (config) => {
|
||||
const next = new Map<ShortcutKey, TActionWithOptionalArgs>();
|
||||
for (const [key, action] of Object.entries(config)) {
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new Error(`Invalid key format: ${key}`);
|
||||
}
|
||||
if (action !== undefined) {
|
||||
// Public type's keys are `ShortcutKey`; trust the caller's typing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
next.set(key as ShortcutKey, action);
|
||||
}
|
||||
}
|
||||
set({
|
||||
keybindings: { ...config },
|
||||
keybindings: next,
|
||||
isCustomized: true,
|
||||
});
|
||||
},
|
||||
|
||||
exportKeybindings: () => {
|
||||
return get().keybindings;
|
||||
return Object.fromEntries(get().keybindings);
|
||||
},
|
||||
|
||||
validateKeybinding: (
|
||||
key: ShortcutKey,
|
||||
action: TActionWithOptionalArgs,
|
||||
) => {
|
||||
const { keybindings } = get();
|
||||
const existingAction = keybindings[key];
|
||||
|
||||
validateKeybinding: ({ key, action }) => {
|
||||
const existingAction = get().keybindings.get(key);
|
||||
if (existingAction && existingAction !== action) {
|
||||
return {
|
||||
key,
|
||||
@@ -143,33 +163,45 @@ export const useKeybindingsStore = create<KeybindingsState>()(
|
||||
newAction: action,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
setIsRecording: (isRecording: boolean) => {
|
||||
setIsRecording: (isRecording) => {
|
||||
set({ isRecording });
|
||||
},
|
||||
|
||||
getKeybindingsForAction: (action: TActionWithOptionalArgs) => {
|
||||
const { keybindings } = get();
|
||||
return Object.keys(keybindings).filter(
|
||||
(key) => keybindings[key as ShortcutKey] === action,
|
||||
) as ShortcutKey[];
|
||||
getKeybindingsForAction: (action) => {
|
||||
const result: ShortcutKey[] = [];
|
||||
for (const [key, mapped] of get().keybindings) {
|
||||
if (mapped === action) result.push(key);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getKeybindingString: (ev: KeyboardEvent) => {
|
||||
return generateKeybindingString(ev) as ShortcutKey | null;
|
||||
},
|
||||
getKeybindingString: (ev) => generateKeybindingString(ev),
|
||||
}),
|
||||
{
|
||||
name: "opencut-keybindings",
|
||||
version: CURRENT_VERSION,
|
||||
partialize: (state) => ({
|
||||
keybindings: state.keybindings,
|
||||
partialize: (state): PersistedState => ({
|
||||
keybindings: Object.fromEntries(state.keybindings),
|
||||
isCustomized: state.isCustomized,
|
||||
}),
|
||||
migrate: (persisted, version) =>
|
||||
runMigrations({ state: persisted, fromVersion: version }),
|
||||
merge: (persisted, current) => {
|
||||
if (!isPersistedState(persisted)) return current;
|
||||
const entries = Object.entries(persisted.keybindings);
|
||||
// Persistence boundary: keys are normalized by the migration chain.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const typedEntries = entries as Array<
|
||||
[ShortcutKey, TActionWithOptionalArgs]
|
||||
>;
|
||||
return {
|
||||
...current,
|
||||
keybindings: new Map(typedEntries),
|
||||
isCustomized: persisted.isCustomized,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -184,68 +216,59 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
|
||||
if (
|
||||
modifierKey === "shift" &&
|
||||
isDOMElement(target) &&
|
||||
isTypableDOMElement({ element: target as HTMLElement })
|
||||
isTypableDOMElement({ element: target })
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${modifierKey}+${key}` as ShortcutKey;
|
||||
return `${modifierKey}+${key}`;
|
||||
}
|
||||
|
||||
if (
|
||||
isDOMElement(target) &&
|
||||
isTypableDOMElement({ element: target as HTMLElement })
|
||||
)
|
||||
if (isDOMElement(target) && isTypableDOMElement({ element: target })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${key}` as ShortcutKey;
|
||||
return key;
|
||||
}
|
||||
|
||||
function getPressedKey(ev: KeyboardEvent): string | null {
|
||||
const key = (ev.key ?? "").toLowerCase();
|
||||
function getPressedKey(ev: KeyboardEvent): Key | null {
|
||||
const raw = (ev.key ?? "").toLowerCase();
|
||||
const code = ev.code ?? "";
|
||||
|
||||
if (code === "Space" || key === " " || key === "spacebar" || key === "space")
|
||||
if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space")
|
||||
return "space";
|
||||
|
||||
if (key.startsWith("arrow")) return key.slice(5);
|
||||
|
||||
if (key === "escape") return "escape";
|
||||
if (key === "tab") return "tab";
|
||||
if (key === "home") return "home";
|
||||
if (key === "end") return "end";
|
||||
if (key === "delete") return "delete";
|
||||
if (key === "backspace") return "backspace";
|
||||
if (raw === "arrowup") return "up";
|
||||
if (raw === "arrowdown") return "down";
|
||||
if (raw === "arrowleft") return "left";
|
||||
if (raw === "arrowright") return "right";
|
||||
|
||||
if (code.startsWith("Key")) {
|
||||
const letter = code.slice(3).toLowerCase();
|
||||
if (letter.length === 1 && letter >= "a" && letter <= "z") return letter;
|
||||
if (isKey(letter)) return letter;
|
||||
}
|
||||
|
||||
// Use physical key position for AZERTY and other non-QWERTY layouts
|
||||
// Use physical key position for AZERTY and other non-QWERTY layouts.
|
||||
if (code.startsWith("Digit")) {
|
||||
const digit = code.slice(5);
|
||||
if (digit.length === 1 && digit >= "0" && digit <= "9") return digit;
|
||||
if (isKey(digit)) return digit;
|
||||
}
|
||||
|
||||
const isDigit = key.length === 1 && key >= "0" && key <= "9";
|
||||
if (isDigit) return key;
|
||||
|
||||
if (key === "/" || key === "." || key === "enter") return key;
|
||||
|
||||
if (isKey(raw)) return raw;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getActiveModifier(ev: KeyboardEvent): string | null {
|
||||
const modifierKeys = {
|
||||
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
|
||||
alt: ev.altKey,
|
||||
shift: ev.shiftKey,
|
||||
};
|
||||
function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null {
|
||||
const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey;
|
||||
const alt = ev.altKey;
|
||||
const shift = ev.shiftKey;
|
||||
|
||||
const activeModifier = Object.keys(modifierKeys)
|
||||
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
||||
.join("+");
|
||||
|
||||
return activeModifier === "" ? null : activeModifier;
|
||||
if (ctrl && alt && shift) return "ctrl+alt+shift";
|
||||
if (ctrl && alt) return "ctrl+alt";
|
||||
if (ctrl && shift) return "ctrl+shift";
|
||||
if (alt && shift) return "alt+shift";
|
||||
if (ctrl) return "ctrl";
|
||||
if (alt) return "alt";
|
||||
if (shift) return "shift";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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 type { TActionWithOptionalArgs } from "@/actions";
|
||||
|
||||
interface V2State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||
|
||||
export function v2ToV3({ state }: { state: unknown }): unknown {
|
||||
const v2 = state as V2State;
|
||||
const v2 = getPersistedKeybindingsState({ state });
|
||||
if (!v2) return state;
|
||||
|
||||
const renames: Record<string, string> = {
|
||||
"split-selected": "split",
|
||||
@@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown {
|
||||
|
||||
const migrated = { ...v2.keybindings };
|
||||
for (const [key, action] of Object.entries(migrated)) {
|
||||
if (action && renames[action]) {
|
||||
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
|
||||
const renamedAction = action ? renames[action] : undefined;
|
||||
if (renamedAction) {
|
||||
migrated[key] = renamedAction;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { TActionWithOptionalArgs } from "@/actions";
|
||||
import type { ShortcutKey } from "@/actions/keybinding";
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V3State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||
|
||||
export function v3ToV4({ state }: { state: unknown }): unknown {
|
||||
const v3 = state as V3State;
|
||||
const v3 = getPersistedKeybindingsState({ state });
|
||||
if (!v3) return state;
|
||||
|
||||
const renames: Record<string, string> = {
|
||||
"paste-selected": "paste-copied",
|
||||
@@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown {
|
||||
|
||||
const migrated = { ...v3.keybindings };
|
||||
for (const [key, action] of Object.entries(migrated)) {
|
||||
if (action && renames[action]) {
|
||||
migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
|
||||
const renamedAction = action ? renames[action] : undefined;
|
||||
if (renamedAction) {
|
||||
migrated[key] = renamedAction;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V4State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||
|
||||
export function v4ToV5({ state }: { state: unknown }): unknown {
|
||||
const v4 = state as V4State;
|
||||
const v4 = getPersistedKeybindingsState({ state });
|
||||
if (!v4) return state;
|
||||
const keybindings = { ...v4.keybindings };
|
||||
|
||||
if (!keybindings.escape) {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V5State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||
|
||||
export function v5ToV6({ state }: { state: unknown }): unknown {
|
||||
const v5 = state as V5State;
|
||||
const v5 = getPersistedKeybindingsState({ state });
|
||||
if (!v5) return state;
|
||||
const keybindings = { ...v5.keybindings };
|
||||
|
||||
if (keybindings.escape === "deselect-all") {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V6State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
import { getPersistedKeybindingsState } from "../persisted-state";
|
||||
|
||||
export function v6ToV7({ state }: { state: unknown }): unknown {
|
||||
const v6 = state as V6State;
|
||||
const v6 = getPersistedKeybindingsState({ state });
|
||||
if (!v6) return state;
|
||||
const keybindings = { ...v6.keybindings };
|
||||
|
||||
for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) {
|
||||
if (keybindings[key] === ("split-element" as never)) {
|
||||
for (const [key, action] of Object.entries(keybindings)) {
|
||||
if (action === "split-element") {
|
||||
keybindings[key] = "split";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void;
|
||||
const boundActions: Partial<Record<TAction, ActionHandler[]>> = {};
|
||||
|
||||
// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
|
||||
export function bindAction<A extends TAction>(
|
||||
action: A,
|
||||
handler: TActionFunc<A>,
|
||||
@@ -24,6 +25,7 @@ export function bindAction<A extends TAction>(
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler).
|
||||
export function unbindAction<A extends TAction>(
|
||||
action: A,
|
||||
handler: TActionFunc<A>,
|
||||
@@ -52,6 +54,7 @@ type InvokeActionFunc = {
|
||||
): void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger.
|
||||
export const invokeAction: InvokeActionFunc = <A extends TAction>(
|
||||
action: A,
|
||||
args?: TArgOfAction<A>,
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { TAction } from "./definitions";
|
||||
|
||||
export type { TAction };
|
||||
|
||||
export type TActionArgsMap = {
|
||||
"seek-forward": { seconds: number } | undefined;
|
||||
"seek-backward": { seconds: number } | undefined;
|
||||
"jump-forward": { seconds: number } | undefined;
|
||||
"jump-backward": { seconds: number } | undefined;
|
||||
"remove-media-asset": { projectId: string; assetId: string };
|
||||
"remove-media-assets": { projectId: string; assetIds: string[] };
|
||||
};
|
||||
|
||||
type TKeysWithValueUndefined<T> = {
|
||||
[K in keyof T]: undefined extends T[K] ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export type TActionWithArgs = keyof TActionArgsMap;
|
||||
|
||||
export type TActionWithOptionalArgs =
|
||||
| TActionWithNoArgs
|
||||
| TKeysWithValueUndefined<TActionArgsMap>;
|
||||
|
||||
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
|
||||
|
||||
export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
|
||||
? TActionArgsMap[A]
|
||||
: undefined;
|
||||
|
||||
export type TActionFunc<A extends TAction> = A extends TActionWithArgs
|
||||
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
|
||||
: (_?: undefined, trigger?: TInvocationTrigger) => void;
|
||||
|
||||
export type TInvocationTrigger = "keypress" | "mouseclick";
|
||||
|
||||
export type TBoundActionList = {
|
||||
[A in TAction]?: Array<TActionFunc<A>>;
|
||||
};
|
||||
|
||||
export type TActionHandlerOptions =
|
||||
| MutableRefObject<boolean>
|
||||
| boolean
|
||||
| undefined;
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { TAction } from "./definitions";
|
||||
|
||||
export type { TAction };
|
||||
|
||||
export type TActionArgsMap = {
|
||||
"seek-forward": { seconds: number } | undefined;
|
||||
"seek-backward": { seconds: number } | undefined;
|
||||
"jump-forward": { seconds: number } | undefined;
|
||||
"jump-backward": { seconds: number } | undefined;
|
||||
"remove-media-asset": { projectId: string; assetId: string };
|
||||
"remove-media-assets": { projectId: string; assetIds: string[] };
|
||||
};
|
||||
|
||||
type TKeysWithValueUndefined<T> = {
|
||||
[K in keyof T]: undefined extends T[K] ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export type TActionWithArgs = keyof TActionArgsMap;
|
||||
|
||||
export type TActionWithOptionalArgs =
|
||||
| TActionWithNoArgs
|
||||
| TKeysWithValueUndefined<TActionArgsMap>;
|
||||
|
||||
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
|
||||
|
||||
export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
|
||||
? TActionArgsMap[A]
|
||||
: undefined;
|
||||
|
||||
export type TActionFunc<A extends TAction> = A extends TActionWithArgs
|
||||
? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
|
||||
: (_?: undefined, trigger?: TInvocationTrigger) => void;
|
||||
|
||||
export type TInvocationTrigger = "keypress" | "mouseclick";
|
||||
|
||||
export type TBoundActionList = {
|
||||
[A in TAction]?: Array<TActionFunc<A>>;
|
||||
};
|
||||
|
||||
export type TActionHandlerOptions =
|
||||
| MutableRefObject<boolean>
|
||||
| boolean
|
||||
| undefined;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "@/actions";
|
||||
import { bindAction, unbindAction } from "@/actions";
|
||||
|
||||
// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive).
|
||||
export function useActionHandler<A extends TAction>(
|
||||
action: A,
|
||||
handler: TActionFunc<A>,
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTimelineStore } from "@/timeline/timeline-store";
|
||||
import { useActionHandler } from "@/actions/use-action-handler";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
addMediaTime,
|
||||
maxMediaTime,
|
||||
mediaTime,
|
||||
mediaTimeFromSeconds,
|
||||
minMediaTime,
|
||||
subMediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
|
||||
import { getElementsAtTime, hasMediaId } from "@/timeline";
|
||||
import { cancelInteraction } from "@/editor/cancel-interaction";
|
||||
@@ -16,6 +25,7 @@ import {
|
||||
clearActiveScope,
|
||||
type ScopeEntry,
|
||||
} from "@/selection/scope";
|
||||
import { useCommittedRef } from "@/hooks/use-committed-ref";
|
||||
|
||||
export function useEditorActions() {
|
||||
const editor = useEditor();
|
||||
@@ -27,47 +37,34 @@ export function useEditorActions() {
|
||||
const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
|
||||
const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
|
||||
const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
|
||||
const hasTimelineSelectionRef = useRef(false);
|
||||
const clearTimelineSelectionRef = useRef(() => {});
|
||||
const clearTimelineActiveSelectionRef = useRef(() => {});
|
||||
const timelineScopeRef = useRef<ScopeEntry | null>(null);
|
||||
const hasTimelineSelection =
|
||||
selectedElements.length > 0 ||
|
||||
selectedKeyframes.length > 0 ||
|
||||
selectedMaskPointSelection !== null;
|
||||
|
||||
hasTimelineSelectionRef.current = hasTimelineSelection;
|
||||
clearTimelineSelectionRef.current = () => {
|
||||
const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection);
|
||||
const clearTimelineSelectionRef = useCommittedRef(() => {
|
||||
editor.selection.clearSelection();
|
||||
};
|
||||
clearTimelineActiveSelectionRef.current = () => {
|
||||
});
|
||||
const clearTimelineActiveSelectionRef = useCommittedRef(() => {
|
||||
editor.selection.clearMostSpecificSelection();
|
||||
};
|
||||
|
||||
if (!timelineScopeRef.current) {
|
||||
timelineScopeRef.current = {
|
||||
hasSelection: () => hasTimelineSelectionRef.current,
|
||||
clear: () => {
|
||||
clearTimelineSelectionRef.current();
|
||||
},
|
||||
clearActive: () => {
|
||||
clearTimelineActiveSelectionRef.current();
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
const [timelineScope] = useState<ScopeEntry>(() => ({
|
||||
hasSelection: () => hasTimelineSelectionRef.current,
|
||||
clear: () => {
|
||||
clearTimelineSelectionRef.current();
|
||||
},
|
||||
clearActive: () => {
|
||||
clearTimelineActiveSelectionRef.current();
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTimelineSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timelineScope = timelineScopeRef.current;
|
||||
if (!timelineScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
return activateScope({ entry: timelineScope });
|
||||
}, [hasTimelineSelection]);
|
||||
}, [hasTimelineSelection, timelineScope]);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-play",
|
||||
@@ -83,7 +80,7 @@ export function useEditorActions() {
|
||||
if (editor.playback.getIsPlaying()) {
|
||||
editor.playback.toggle();
|
||||
}
|
||||
editor.playback.seek({ time: 0 });
|
||||
editor.playback.seek({ time: ZERO_MEDIA_TIME });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -92,11 +89,15 @@ export function useEditorActions() {
|
||||
"seek-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
time: minMediaTime({
|
||||
a: editor.timeline.getTotalDuration(),
|
||||
b: addMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -106,8 +107,15 @@ export function useEditorActions() {
|
||||
"seek-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
time: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -117,15 +125,20 @@ export function useEditorActions() {
|
||||
"frame-step-forward",
|
||||
() => {
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const ticksPerFrame = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + ticksPerFrame,
|
||||
const ticksPerFrame = mediaTime({
|
||||
ticks: Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
),
|
||||
});
|
||||
editor.playback.seek({
|
||||
time: minMediaTime({
|
||||
a: editor.timeline.getTotalDuration(),
|
||||
b: addMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: ticksPerFrame,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -134,11 +147,19 @@ export function useEditorActions() {
|
||||
"frame-step-backward",
|
||||
() => {
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const ticksPerFrame = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
const ticksPerFrame = mediaTime({
|
||||
ticks: Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
),
|
||||
});
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
|
||||
time: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: ticksPerFrame,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -148,11 +169,15 @@ export function useEditorActions() {
|
||||
"jump-forward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.min(
|
||||
editor.timeline.getTotalDuration(),
|
||||
editor.playback.getCurrentTime() + seconds,
|
||||
),
|
||||
time: minMediaTime({
|
||||
a: editor.timeline.getTotalDuration(),
|
||||
b: addMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -162,8 +187,15 @@ export function useEditorActions() {
|
||||
"jump-backward",
|
||||
(args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
const delta = mediaTimeFromSeconds({ seconds });
|
||||
editor.playback.seek({
|
||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
||||
time: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: subMediaTime({
|
||||
a: editor.playback.getCurrentTime(),
|
||||
b: delta,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
@@ -172,7 +204,7 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"goto-start",
|
||||
() => {
|
||||
editor.playback.seek({ time: 0 });
|
||||
editor.playback.seek({ time: ZERO_MEDIA_TIME });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -273,7 +305,7 @@ export function useEditorActions() {
|
||||
if (!selectedMaskPointSelection) {
|
||||
return;
|
||||
}
|
||||
editor.timeline.deleteCustomMaskPoints({
|
||||
editor.timeline.deleteFreeformPathMaskPoints({
|
||||
trackId: selectedMaskPointSelection.trackId,
|
||||
elementId: selectedMaskPointSelection.elementId,
|
||||
maskId: selectedMaskPointSelection.maskId,
|
||||
|
||||
@@ -33,7 +33,7 @@ export function useKeybindingsListener() {
|
||||
const isTextInput =
|
||||
activeElement instanceof HTMLElement &&
|
||||
isTypableDOMElement({ element: activeElement });
|
||||
const boundAction = binding ? keybindings[binding] : undefined;
|
||||
const boundAction = binding ? keybindings.get(binding) : undefined;
|
||||
|
||||
if (normalizedKey === "escape" && isTextInput) {
|
||||
activeElement.blur();
|
||||
|
||||
@@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() {
|
||||
const { keybindings } = useKeybindingsStore();
|
||||
|
||||
const shortcuts = useMemo(() => {
|
||||
const result: KeyboardShortcut[] = [];
|
||||
const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {};
|
||||
const actionToKeys = new Map<TActionWithOptionalArgs, string[]>();
|
||||
|
||||
for (const [key, action] of Object.entries(keybindings) as Array<
|
||||
[string, TActionWithOptionalArgs | undefined]
|
||||
>) {
|
||||
if (action) {
|
||||
if (!actionToKeys[action]) {
|
||||
actionToKeys[action] = [];
|
||||
}
|
||||
actionToKeys[action].push(formatKey({ key }));
|
||||
for (const [key, action] of keybindings) {
|
||||
const existing = actionToKeys.get(action);
|
||||
if (existing) {
|
||||
existing.push(formatKey({ key }));
|
||||
} else {
|
||||
actionToKeys.set(action, [formatKey({ key })]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [action, keys] of Object.entries(actionToKeys) as Array<
|
||||
[TActionWithOptionalArgs, string[]]
|
||||
>) {
|
||||
const result: KeyboardShortcut[] = [];
|
||||
for (const [action, keys] of actionToKeys) {
|
||||
const actionDef = ACTIONS[action];
|
||||
if (!actionDef) {
|
||||
continue;
|
||||
}
|
||||
if (!actionDef) continue;
|
||||
result.push({
|
||||
id: action,
|
||||
keys,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
NormalizedCubicBezier,
|
||||
ScalarAnimationKey,
|
||||
} from "@/animation/types";
|
||||
import { roundMediaTime } from "@/wasm";
|
||||
|
||||
const VALUE_EPSILON = 1e-6;
|
||||
|
||||
@@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
|
||||
|
||||
return {
|
||||
rightHandle: {
|
||||
dt: spanTime * x1,
|
||||
dt: roundMediaTime({ time: spanTime * x1 }),
|
||||
dv: effectiveSpanValue * y1,
|
||||
},
|
||||
leftHandle: {
|
||||
dt: spanTime * (x2 - 1),
|
||||
dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
|
||||
dv: effectiveSpanValue * (y2 - 1),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { Effect } from "@/effects/types";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
EffectParamPath,
|
||||
} from "@/animation/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { removeElementKeyframe } from "./keyframes";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
@@ -56,23 +55,26 @@ export function parseEffectParamPath({
|
||||
}
|
||||
|
||||
export function resolveEffectParamsAtTime({
|
||||
effect,
|
||||
effectId,
|
||||
params,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
effect: Effect;
|
||||
effectId: string;
|
||||
params: ParamValues;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
const resolved: ParamValues = {};
|
||||
|
||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
||||
resolved[paramKey] = animations?.bindings[path]
|
||||
for (const [paramKey, staticValue] of Object.entries(params)) {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
resolved[paramKey] = animations?.[path]
|
||||
? resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime,
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: staticValue,
|
||||
})
|
||||
: staticValue;
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ChannelData,
|
||||
ScalarAnimationChannel,
|
||||
ScalarGraphChannel,
|
||||
ScalarGraphKeyframeContext,
|
||||
} from "@/animation/types";
|
||||
import type { ChannelEasingMode } from "@/params";
|
||||
import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
|
||||
import { isScalarChannel } from "./interpolation";
|
||||
|
||||
export interface EditableScalarChannels {
|
||||
binding: AnimationBindingInstance;
|
||||
easingMode: ChannelEasingMode;
|
||||
channels: ScalarGraphChannel[];
|
||||
}
|
||||
|
||||
function isScalarAnimationChannel(
|
||||
channel: ElementAnimations["channels"][string],
|
||||
channel: ChannelData | undefined,
|
||||
): channel is ScalarAnimationChannel {
|
||||
return channel?.kind === "scalar";
|
||||
return isLeafChannelData(channel) && isScalarChannel(channel);
|
||||
}
|
||||
|
||||
function getEasingModeForChannelData({
|
||||
data,
|
||||
}: {
|
||||
data: ChannelData | undefined;
|
||||
}): ChannelEasingMode {
|
||||
return isCompositeChannelData(data) &&
|
||||
["r", "g", "b", "a"].every((componentKey) => componentKey in data)
|
||||
? "shared"
|
||||
: "independent";
|
||||
}
|
||||
|
||||
export function getEditableScalarChannels({
|
||||
@@ -25,13 +39,15 @@ export function getEditableScalarChannels({
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
}): EditableScalarChannels | null {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
const data = animations?.[propertyPath];
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channels = binding.components.flatMap((component) => {
|
||||
const channel = animations?.channels[component.channelId];
|
||||
const channelEntries = isLeafChannelData(data)
|
||||
? [["value", data] as const]
|
||||
: Object.entries(data);
|
||||
const channels = channelEntries.flatMap(([componentKey, channel]) => {
|
||||
if (!isScalarAnimationChannel(channel)) {
|
||||
return [];
|
||||
}
|
||||
@@ -39,14 +55,13 @@ export function getEditableScalarChannels({
|
||||
return [
|
||||
{
|
||||
propertyPath,
|
||||
componentKey: component.key,
|
||||
channelId: component.channelId,
|
||||
componentKey,
|
||||
channel,
|
||||
} satisfies ScalarGraphChannel,
|
||||
];
|
||||
});
|
||||
|
||||
return { binding, channels };
|
||||
return { easingMode: getEasingModeForChannelData({ data }), channels };
|
||||
}
|
||||
|
||||
export function getEditableScalarChannel({
|
||||
|
||||
@@ -2,11 +2,7 @@ import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/animation/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import {
|
||||
getGraphicDefinition,
|
||||
resolveGraphicParams,
|
||||
} from "@/graphics";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||
@@ -39,33 +35,29 @@ export function parseGraphicParamPath({
|
||||
}
|
||||
|
||||
export function resolveGraphicParamsAtTime({
|
||||
element,
|
||||
params,
|
||||
definitions,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
params: ParamValues;
|
||||
definitions: ParamDefinition[];
|
||||
animations?: ElementAnimations;
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const baseParams = resolveGraphicParams(definition, element.params);
|
||||
const resolved: ParamValues = { ...baseParams };
|
||||
const resolved: ParamValues = { ...params };
|
||||
|
||||
for (const param of definition.params) {
|
||||
for (const param of definitions) {
|
||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||
if (!element.animations?.bindings[path]) {
|
||||
if (!animations?.[path]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||
animations: element.animations,
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseParams[param.key] ?? param.default,
|
||||
fallbackValue: params[param.key] ?? param.default,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,31 +16,14 @@ export {
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertElementKeyframe,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveAnimationPathValueAtTime,
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeById,
|
||||
@@ -75,15 +58,6 @@ export {
|
||||
resolveEffectParamsAtTime,
|
||||
} from "./effect-param-channel";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
coerceAnimationValueForParam,
|
||||
resolveAnimationTarget,
|
||||
getParamValueKind,
|
||||
getParamDefaultInterpolation,
|
||||
type AnimationPathDescriptor,
|
||||
} from "./target-resolver";
|
||||
|
||||
export {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
@@ -91,6 +65,8 @@ export {
|
||||
} from "./property-groups";
|
||||
|
||||
export {
|
||||
type EasingMode,
|
||||
getEasingModeForKind,
|
||||
} from "./binding-values";
|
||||
isAnimationPath,
|
||||
isAnimationPropertyPath,
|
||||
} from "./path";
|
||||
|
||||
export type { NumericSpec } from "./types";
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import type {
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
Channel,
|
||||
DiscreteAnimationChannel,
|
||||
DiscreteValue,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
} from "@/animation/types";
|
||||
import { clamp } from "@/utils/math";
|
||||
import type { ParamValue } from "@/params";
|
||||
import { mediaTime } from "@/wasm";
|
||||
import {
|
||||
getBezierPoint,
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
solveBezierProgressForTime,
|
||||
} from "./bezier";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
function byTimeAscending({
|
||||
leftTime,
|
||||
@@ -63,9 +65,13 @@ function normalizeRightHandle({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||
const span = mediaTime({
|
||||
ticks: Math.max(1, rightKey.time - leftKey.time),
|
||||
});
|
||||
return {
|
||||
dt: Math.min(span, Math.max(0, handle.dt)),
|
||||
dt: mediaTime({
|
||||
ticks: Math.min(span, Math.max(0, handle.dt)),
|
||||
}),
|
||||
dv: handle.dv,
|
||||
};
|
||||
}
|
||||
@@ -83,9 +89,13 @@ function normalizeLeftHandle({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(1, rightKey.time - leftKey.time);
|
||||
const span = mediaTime({
|
||||
ticks: Math.max(1, rightKey.time - leftKey.time),
|
||||
});
|
||||
return {
|
||||
dt: Math.max(-span, Math.min(0, handle.dt)),
|
||||
dt: mediaTime({
|
||||
ticks: Math.max(-span, Math.min(0, handle.dt)),
|
||||
}),
|
||||
dv: handle.dv,
|
||||
};
|
||||
}
|
||||
@@ -102,7 +112,7 @@ function normalizeScalarKey({
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScalarChannel({
|
||||
export function normalizeScalarChannel({
|
||||
channel,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel;
|
||||
@@ -145,17 +155,11 @@ function normalizeScalarChannel({
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||
export function normalizeDiscreteChannel({
|
||||
channel,
|
||||
}: {
|
||||
channel: TChannel;
|
||||
}): TChannel {
|
||||
if (channel.kind === "scalar") {
|
||||
return normalizeScalarChannel({
|
||||
channel,
|
||||
}) as TChannel;
|
||||
}
|
||||
|
||||
channel: DiscreteAnimationChannel;
|
||||
}): DiscreteAnimationChannel {
|
||||
return {
|
||||
...channel,
|
||||
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
|
||||
@@ -164,7 +168,39 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||
rightTime: rightKeyframe.time,
|
||||
}),
|
||||
),
|
||||
} as TChannel;
|
||||
};
|
||||
}
|
||||
|
||||
export function isScalarChannel(channel: AnimationChannel): channel is ScalarAnimationChannel {
|
||||
return (
|
||||
"extrapolation" in channel ||
|
||||
channel.keys.some((keyframe) => "segmentToNext" in keyframe)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeChannel({
|
||||
channel,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel;
|
||||
}): ScalarAnimationChannel;
|
||||
export function normalizeChannel({
|
||||
channel,
|
||||
}: {
|
||||
channel: DiscreteAnimationChannel;
|
||||
}): DiscreteAnimationChannel;
|
||||
export function normalizeChannel({
|
||||
channel,
|
||||
}: {
|
||||
channel: AnimationChannel;
|
||||
}): AnimationChannel;
|
||||
export function normalizeChannel({
|
||||
channel,
|
||||
}: {
|
||||
channel: AnimationChannel;
|
||||
}): AnimationChannel {
|
||||
return isScalarChannel(channel)
|
||||
? normalizeScalarChannel({ channel })
|
||||
: normalizeDiscreteChannel({ channel });
|
||||
}
|
||||
|
||||
function extrapolateScalarEdge({
|
||||
@@ -207,7 +243,7 @@ export function getScalarChannelValueAtTime({
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel | undefined;
|
||||
channel: Channel<number> | undefined;
|
||||
time: number;
|
||||
fallbackValue: number;
|
||||
}): number {
|
||||
@@ -215,9 +251,7 @@ export function getScalarChannelValueAtTime({
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const normalizedChannel = normalizeChannel({
|
||||
channel,
|
||||
});
|
||||
const normalizedChannel = normalizeScalarChannel({ channel });
|
||||
const firstKey = normalizedChannel.keys[0];
|
||||
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
|
||||
if (!firstKey || !lastKey) {
|
||||
@@ -319,7 +353,7 @@ export function getDiscreteChannelValueAtTime({
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: DiscreteAnimationChannel | undefined;
|
||||
channel: Channel<DiscreteValue> | undefined;
|
||||
time: number;
|
||||
fallbackValue: DiscreteValue;
|
||||
}): DiscreteValue {
|
||||
@@ -327,9 +361,7 @@ export function getDiscreteChannelValueAtTime({
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const normalizedChannel = normalizeChannel({
|
||||
channel,
|
||||
});
|
||||
const normalizedChannel = normalizeDiscreteChannel({ channel });
|
||||
let currentValue = fallbackValue;
|
||||
for (const key of normalizedChannel.keys) {
|
||||
if (time < key.time) {
|
||||
@@ -340,6 +372,24 @@ export function getDiscreteChannelValueAtTime({
|
||||
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({
|
||||
channel,
|
||||
time,
|
||||
@@ -347,14 +397,14 @@ export function getChannelValueAtTime({
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: AnimationValue;
|
||||
}): AnimationValue {
|
||||
fallbackValue: ParamValue;
|
||||
}): ParamValue {
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (channel.kind === "scalar") {
|
||||
return typeof fallbackValue === "number"
|
||||
if (typeof fallbackValue === "number") {
|
||||
return isScalarChannel(channel)
|
||||
? getScalarChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
@@ -368,7 +418,7 @@ export function getChannelValueAtTime({
|
||||
}
|
||||
|
||||
return getDiscreteChannelValueAtTime({
|
||||
channel,
|
||||
channel: isScalarChannel(channel) ? undefined : channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationChannel,
|
||||
AnimationPath,
|
||||
ChannelData,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/animation/types";
|
||||
import { formatLinearRgba } from "@/params";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
} from "./binding-values";
|
||||
getChannelEntriesFromData,
|
||||
isAnimationStorageKey,
|
||||
} from "./channel-data";
|
||||
import {
|
||||
getChannelValueAtTime,
|
||||
getScalarSegmentInterpolation,
|
||||
isScalarChannel,
|
||||
} from "./interpolation";
|
||||
import { isAnimationPath } from "./target-resolver";
|
||||
import { isAnimationPath } from "./path";
|
||||
|
||||
function getBindingFallbackValue({
|
||||
function getChannelFallbackValue({
|
||||
channel,
|
||||
}: {
|
||||
channel: ElementAnimations["channels"][string];
|
||||
channel: AnimationChannel;
|
||||
}) {
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return channel?.kind === "discrete" ? false : 0;
|
||||
if (channel.keys.length === 0) {
|
||||
return isScalarChannel(channel) ? 0 : false;
|
||||
}
|
||||
|
||||
return channel.keys[0].value;
|
||||
}
|
||||
|
||||
interface BindingKeyframeMatch {
|
||||
interface ChannelKeyframeMatch {
|
||||
componentKey: string;
|
||||
componentIndex: number;
|
||||
channel: AnimationChannel;
|
||||
keyframe: AnimationChannel["keys"][number];
|
||||
}
|
||||
|
||||
function getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
function getChannelKeyframeMatches({
|
||||
data,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
}): BindingKeyframeMatch[] {
|
||||
return binding.components.flatMap((component, componentIndex) => {
|
||||
const channel = animations.channels[component.channelId];
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
data: ChannelData | undefined;
|
||||
}): ChannelKeyframeMatch[] {
|
||||
return getChannelEntriesFromData({ data }).flatMap(
|
||||
([componentKey, channel], componentIndex) => {
|
||||
if (channel.keys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return channel.keys.map((keyframe) => ({
|
||||
componentIndex,
|
||||
channel,
|
||||
keyframe,
|
||||
}));
|
||||
});
|
||||
return channel.keys.map((keyframe) => ({
|
||||
componentKey,
|
||||
componentIndex,
|
||||
channel,
|
||||
keyframe,
|
||||
}));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function getUniqueBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
function getUniqueChannelKeyframeMatches({
|
||||
data,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
}): BindingKeyframeMatch[] {
|
||||
const sortedMatches = getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
data: ChannelData | undefined;
|
||||
}): ChannelKeyframeMatch[] {
|
||||
const sortedMatches = getChannelKeyframeMatches({
|
||||
data,
|
||||
}).sort(
|
||||
(leftMatch, rightMatch) =>
|
||||
leftMatch.keyframe.time - rightMatch.keyframe.time ||
|
||||
leftMatch.componentIndex - rightMatch.componentIndex,
|
||||
);
|
||||
const uniqueMatches: BindingKeyframeMatch[] = [];
|
||||
const uniqueMatches: ChannelKeyframeMatch[] = [];
|
||||
|
||||
for (const match of sortedMatches) {
|
||||
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
|
||||
@@ -92,11 +92,11 @@ function getUniqueBindingKeyframeMatches({
|
||||
return uniqueMatches;
|
||||
}
|
||||
|
||||
function getPreferredBindingKeyframeMatch({
|
||||
function getPreferredChannelKeyframeMatch({
|
||||
matches,
|
||||
}: {
|
||||
matches: BindingKeyframeMatch[];
|
||||
}): BindingKeyframeMatch | null {
|
||||
matches: ChannelKeyframeMatch[];
|
||||
}): ChannelKeyframeMatch | null {
|
||||
return (
|
||||
matches.find((match) => match.componentIndex === 0) ??
|
||||
matches[0] ??
|
||||
@@ -104,35 +104,67 @@ function getPreferredBindingKeyframeMatch({
|
||||
);
|
||||
}
|
||||
|
||||
function getComposedBindingValueAtTime({
|
||||
animations,
|
||||
binding,
|
||||
function getChannelValue({
|
||||
channel,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
channel: AnimationChannel;
|
||||
time: number;
|
||||
}) {
|
||||
const componentValues = Object.fromEntries(
|
||||
binding.components.map((component) => {
|
||||
const channel = animations.channels[component.channelId];
|
||||
return [
|
||||
component.key,
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue: getBindingFallbackValue({ channel }),
|
||||
}),
|
||||
];
|
||||
}),
|
||||
) as Record<string, AnimationComponentValue | undefined>;
|
||||
|
||||
return composeAnimationValue({
|
||||
binding,
|
||||
componentValues,
|
||||
const fallbackValue = getChannelFallbackValue({ channel });
|
||||
if (typeof fallbackValue === "number") {
|
||||
return getChannelValueAtTime({
|
||||
channel: isScalarChannel(channel) ? channel : undefined,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
return getChannelValueAtTime({
|
||||
channel: !isScalarChannel(channel) ? channel : undefined,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
|
||||
function getComposedChannelDataValueAtTime({
|
||||
data,
|
||||
time,
|
||||
}: {
|
||||
data: ChannelData | undefined;
|
||||
time: number;
|
||||
}) {
|
||||
const entries = getChannelEntriesFromData({ data });
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (entries.length === 1 && entries[0]?.[0] === "value") {
|
||||
return getChannelValue({ channel: entries[0][1], time });
|
||||
}
|
||||
|
||||
const componentValues = Object.fromEntries(
|
||||
entries.map(([componentKey, channel]) => [
|
||||
componentKey,
|
||||
getChannelValue({ channel, time }),
|
||||
]),
|
||||
);
|
||||
if (
|
||||
typeof componentValues.r === "number" &&
|
||||
typeof componentValues.g === "number" &&
|
||||
typeof componentValues.b === "number" &&
|
||||
typeof componentValues.a === "number"
|
||||
) {
|
||||
return formatLinearRgba({
|
||||
color: {
|
||||
r: componentValues.r,
|
||||
g: componentValues.g,
|
||||
b: componentValues.b,
|
||||
a: componentValues.a,
|
||||
},
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getKeyframeInterpolation({
|
||||
channel,
|
||||
keyframe,
|
||||
@@ -140,25 +172,22 @@ function getKeyframeInterpolation({
|
||||
channel: AnimationChannel;
|
||||
keyframe: AnimationChannel["keys"][number];
|
||||
}) {
|
||||
return channel.kind === "scalar" && "segmentToNext" in keyframe
|
||||
return isScalarChannel(channel) && "segmentToNext" in keyframe
|
||||
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
|
||||
: "hold";
|
||||
}
|
||||
|
||||
function toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
data,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
data: ChannelData | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeMatch: BindingKeyframeMatch;
|
||||
keyframeMatch: ChannelKeyframeMatch;
|
||||
}): ElementKeyframe | null {
|
||||
const value = getComposedBindingValueAtTime({
|
||||
animations,
|
||||
binding,
|
||||
const value = getComposedChannelDataValueAtTime({
|
||||
data,
|
||||
time: keyframeMatch.keyframe.time,
|
||||
});
|
||||
if (value === null) {
|
||||
@@ -186,19 +215,19 @@ export function getElementKeyframes({
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(animations.bindings).flatMap(
|
||||
([propertyPath, binding]) => {
|
||||
if (!binding || !isAnimationPath(propertyPath)) {
|
||||
return Object.entries(animations).filter(([key]) =>
|
||||
isAnimationStorageKey({ key }),
|
||||
).flatMap(
|
||||
([propertyPath, data]) => {
|
||||
if (!data || !isAnimationPath(propertyPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getUniqueBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
return getUniqueChannelKeyframeMatches({
|
||||
data,
|
||||
}).flatMap((keyframeMatch) => {
|
||||
const keyframe = toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
data,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
});
|
||||
@@ -219,13 +248,8 @@ export function hasKeyframesForPath({
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
}): boolean {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return binding.components.some((component) =>
|
||||
Boolean(animations?.channels[component.channelId]?.keys.length),
|
||||
return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some(
|
||||
([, channel]) => channel.keys.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,24 +262,22 @@ export function getKeyframeAtTime({
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
}): ElementKeyframe | null {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
const data = animations?.[propertyPath];
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyframeMatch = getPreferredBindingKeyframeMatch({
|
||||
matches: getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).filter(({ keyframe }) => keyframe.time === time),
|
||||
const keyframeMatch = getPreferredChannelKeyframeMatch({
|
||||
matches: getChannelKeyframeMatches({ data }).filter(
|
||||
({ keyframe }) => keyframe.time === time,
|
||||
),
|
||||
});
|
||||
if (!keyframeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
data,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
});
|
||||
@@ -270,24 +292,22 @@ export function getKeyframeById({
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}): ElementKeyframe | null {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
const data = animations?.[propertyPath];
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyframeMatch = getPreferredBindingKeyframeMatch({
|
||||
matches: getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).filter(({ keyframe }) => keyframe.id === keyframeId),
|
||||
const keyframeMatch = getPreferredChannelKeyframeMatch({
|
||||
matches: getChannelKeyframeMatches({ data }).filter(
|
||||
({ keyframe }) => keyframe.id === keyframeId,
|
||||
),
|
||||
});
|
||||
if (!keyframeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
data,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
});
|
||||
|
||||
+425
-402
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -1,19 +1,13 @@
|
||||
import type {
|
||||
AnimationColorPropertyPath,
|
||||
AnimationNumericPropertyPath,
|
||||
AnimationPath,
|
||||
AnimationPropertyPath,
|
||||
AnimationValueForPath,
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
decomposeAnimationValue,
|
||||
} from "./binding-values";
|
||||
import { formatLinearRgba, parseColorToLinearRgba } from "@/params";
|
||||
import type { ParamValue } from "@/params";
|
||||
import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
|
||||
import {
|
||||
getChannelValueAtTime,
|
||||
isScalarChannel,
|
||||
} from "./interpolation";
|
||||
|
||||
export function getElementLocalTime({
|
||||
@@ -37,148 +31,123 @@ export function getElementLocalTime({
|
||||
return localTime;
|
||||
}
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: {
|
||||
x: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.x,
|
||||
}),
|
||||
y: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.positionY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position.y,
|
||||
}),
|
||||
},
|
||||
scaleX: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleX,
|
||||
}),
|
||||
scaleY: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleY,
|
||||
}),
|
||||
rotate: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationNumericPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationColorPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
||||
export function resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
fallbackValue,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: TPath;
|
||||
propertyPath: AnimationPath;
|
||||
localTime: number;
|
||||
fallbackValue: AnimationValueForPath<TPath>;
|
||||
}): AnimationValueForPath<TPath> {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
fallbackValue: number;
|
||||
}): number;
|
||||
export function resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
fallbackValue,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
localTime: number;
|
||||
fallbackValue: string;
|
||||
}): string;
|
||||
export function resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
fallbackValue,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
localTime: number;
|
||||
fallbackValue: boolean;
|
||||
}): boolean;
|
||||
export function resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
fallbackValue,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
localTime: number;
|
||||
fallbackValue: ParamValue;
|
||||
}): ParamValue;
|
||||
export function resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
fallbackValue,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
localTime: number;
|
||||
fallbackValue: ParamValue;
|
||||
}): ParamValue {
|
||||
const data = animations?.[propertyPath];
|
||||
if (!data) {
|
||||
return fallbackValue;
|
||||
}
|
||||
if (isLeafChannelData(data)) {
|
||||
if (typeof fallbackValue === "number") {
|
||||
return getChannelValueAtTime({
|
||||
channel: isScalarChannel(data) ? data : undefined,
|
||||
time: localTime,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
if (typeof fallbackValue === "string" || typeof fallbackValue === "boolean") {
|
||||
return getChannelValueAtTime({
|
||||
channel: !isScalarChannel(data) ? data : undefined,
|
||||
time: localTime,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
return fallbackValue;
|
||||
}
|
||||
if (!isCompositeChannelData(data)) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const fallbackComponents = decomposeAnimationValue({
|
||||
kind: binding.kind,
|
||||
value: fallbackValue,
|
||||
if (
|
||||
typeof fallbackValue !== "string" ||
|
||||
!("r" in data) ||
|
||||
!("g" in data) ||
|
||||
!("b" in data) ||
|
||||
!("a" in data)
|
||||
) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const fallbackComponents = parseColorToLinearRgba({ color: fallbackValue });
|
||||
if (fallbackComponents === null) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return formatLinearRgba({
|
||||
color: {
|
||||
r: getChannelValueAtTime({
|
||||
channel: data.r && isScalarChannel(data.r) ? data.r : undefined,
|
||||
time: localTime,
|
||||
fallbackValue: fallbackComponents.r,
|
||||
}),
|
||||
g: getChannelValueAtTime({
|
||||
channel: data.g && isScalarChannel(data.g) ? data.g : undefined,
|
||||
time: localTime,
|
||||
fallbackValue: fallbackComponents.g,
|
||||
}),
|
||||
b: getChannelValueAtTime({
|
||||
channel: data.b && isScalarChannel(data.b) ? data.b : undefined,
|
||||
time: localTime,
|
||||
fallbackValue: fallbackComponents.b,
|
||||
}),
|
||||
a: getChannelValueAtTime({
|
||||
channel: data.a && isScalarChannel(data.a) ? data.a : undefined,
|
||||
time: localTime,
|
||||
fallbackValue: fallbackComponents.a,
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (!fallbackComponents) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const componentValues = Object.fromEntries(
|
||||
binding.components.map((component) => {
|
||||
const channel = animations?.channels[component.channelId];
|
||||
return [
|
||||
component.key,
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time: localTime,
|
||||
fallbackValue:
|
||||
fallbackComponents[component.key] ??
|
||||
(channel?.kind === "discrete" ? false : 0),
|
||||
}),
|
||||
];
|
||||
}),
|
||||
) as Record<string, AnimationComponentValue | undefined>;
|
||||
return (composeAnimationValue({
|
||||
binding,
|
||||
componentValues,
|
||||
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,4 +1,5 @@
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
import type { ParamValue } from "@/params";
|
||||
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.positionX",
|
||||
@@ -20,10 +21,7 @@ export const ANIMATION_PROPERTY_PATHS = [
|
||||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||
export type GraphicParamPath = `params.${string}`;
|
||||
export type EffectParamPath = `effects.${string}.params.${string}`;
|
||||
export type AnimationPath =
|
||||
| AnimationPropertyPath
|
||||
| GraphicParamPath
|
||||
| EffectParamPath;
|
||||
export type AnimationPath = string;
|
||||
|
||||
export const ANIMATION_PROPERTY_GROUPS = {
|
||||
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
||||
@@ -31,38 +29,21 @@ export const ANIMATION_PROPERTY_GROUPS = {
|
||||
|
||||
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||
|
||||
export type VectorValue = { x: number; y: number };
|
||||
export type DiscreteValue = boolean | string;
|
||||
export type AnimationValue = number | string | boolean | VectorValue;
|
||||
export interface AnimationPropertyValueMap {
|
||||
"transform.positionX": number;
|
||||
"transform.positionY": number;
|
||||
"transform.scaleX": number;
|
||||
"transform.scaleY": number;
|
||||
"transform.rotate": number;
|
||||
opacity: number;
|
||||
volume: number;
|
||||
color: string;
|
||||
"background.color": string;
|
||||
"background.paddingX": number;
|
||||
"background.paddingY": number;
|
||||
"background.offsetX": number;
|
||||
"background.offsetY": number;
|
||||
"background.cornerRadius": number;
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
export type DynamicAnimationPathValue = ParamValues[string];
|
||||
export type AnimationValueForPath<TPath extends AnimationPath> =
|
||||
TPath extends AnimationPropertyPath
|
||||
? AnimationPropertyValueMap[TPath]
|
||||
: TPath extends GraphicParamPath | EffectParamPath
|
||||
? DynamicAnimationPathValue
|
||||
: never;
|
||||
export type AnimationNumericPropertyPath = {
|
||||
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
|
||||
}[AnimationPropertyPath];
|
||||
export type AnimationColorPropertyPath = {
|
||||
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
|
||||
}[AnimationPropertyPath];
|
||||
export type AnimationColorPropertyPath = Extract<
|
||||
AnimationPropertyPath,
|
||||
"color" | "background.color"
|
||||
>;
|
||||
export type AnimationNumericPropertyPath = Exclude<
|
||||
AnimationPropertyPath,
|
||||
AnimationColorPropertyPath
|
||||
>;
|
||||
|
||||
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||
export type DiscreteKeyframeInterpolation = "hold";
|
||||
@@ -70,20 +51,18 @@ export type AnimationInterpolation =
|
||||
| ContinuousKeyframeInterpolation
|
||||
| DiscreteKeyframeInterpolation;
|
||||
|
||||
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
|
||||
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
|
||||
export type ScalarSegmentType = "step" | "linear" | "bezier";
|
||||
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
|
||||
export type ChannelExtrapolationMode = "hold" | "linear";
|
||||
|
||||
export interface CurveHandle {
|
||||
dt: number;
|
||||
dt: MediaTime;
|
||||
dv: number;
|
||||
}
|
||||
|
||||
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
|
||||
interface BaseAnimationKeyframe<TValue extends ParamValue> {
|
||||
id: string;
|
||||
time: number; // relative to element start time
|
||||
time: MediaTime; // relative to element start time
|
||||
value: TValue;
|
||||
}
|
||||
|
||||
@@ -94,13 +73,16 @@ export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
|
||||
tangentMode: TangentMode;
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationKey
|
||||
extends BaseAnimationKeyframe<DiscreteValue> {}
|
||||
export type DiscreteAnimationKey = BaseAnimationKeyframe<DiscreteValue>;
|
||||
|
||||
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
|
||||
export type Keyframe<TValue extends ParamValue = ParamValue> =
|
||||
TValue extends number
|
||||
? ScalarAnimationKey
|
||||
: TValue extends DiscreteValue
|
||||
? DiscreteAnimationKey
|
||||
: never;
|
||||
|
||||
export interface ScalarAnimationChannel {
|
||||
kind: "scalar";
|
||||
export interface ScalarChannel {
|
||||
keys: ScalarAnimationKey[];
|
||||
extrapolation?: {
|
||||
before: ChannelExtrapolationMode;
|
||||
@@ -108,72 +90,26 @@ export interface ScalarAnimationChannel {
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationChannel {
|
||||
kind: "discrete";
|
||||
export interface DiscreteChannel {
|
||||
keys: DiscreteAnimationKey[];
|
||||
}
|
||||
|
||||
export type AnimationChannel =
|
||||
| ScalarAnimationChannel
|
||||
| DiscreteAnimationChannel;
|
||||
export type Channel<TValue extends ParamValue = ParamValue> =
|
||||
TValue extends number
|
||||
? ScalarChannel
|
||||
: TValue extends DiscreteValue
|
||||
? DiscreteChannel
|
||||
: never;
|
||||
|
||||
export type ElementAnimationChannelMap = Record<
|
||||
string,
|
||||
AnimationChannel | undefined
|
||||
>;
|
||||
export type ScalarAnimationChannel = Channel<number>;
|
||||
export type DiscreteAnimationChannel = Channel<DiscreteValue>;
|
||||
export type AnimationChannel = Channel;
|
||||
|
||||
export interface AnimationBindingComponent<TKey extends string = string> {
|
||||
key: TKey;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
interface BaseAnimationBinding<
|
||||
TKind extends AnimationBindingKind,
|
||||
TComponentKey extends string,
|
||||
> {
|
||||
path: AnimationPath;
|
||||
kind: TKind;
|
||||
components: AnimationBindingComponent<TComponentKey>[];
|
||||
}
|
||||
|
||||
export interface NumberAnimationBinding
|
||||
extends BaseAnimationBinding<"number", "value"> {}
|
||||
|
||||
export interface Vector2AnimationBinding
|
||||
extends BaseAnimationBinding<"vector2", "x" | "y"> {}
|
||||
|
||||
export interface ColorAnimationBinding
|
||||
extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
|
||||
colorSpace: "srgb-linear";
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationBinding
|
||||
extends BaseAnimationBinding<"discrete", "value"> {}
|
||||
|
||||
export type AnimationBindingInstance =
|
||||
| NumberAnimationBinding
|
||||
| Vector2AnimationBinding
|
||||
| ColorAnimationBinding
|
||||
| DiscreteAnimationBinding;
|
||||
|
||||
export interface AnimationBindingByKind {
|
||||
number: NumberAnimationBinding;
|
||||
vector2: Vector2AnimationBinding;
|
||||
color: ColorAnimationBinding;
|
||||
discrete: DiscreteAnimationBinding;
|
||||
}
|
||||
|
||||
export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
|
||||
AnimationBindingByKind[TKind];
|
||||
|
||||
export type ElementAnimationBindingMap = Record<
|
||||
string,
|
||||
AnimationBindingInstance | undefined
|
||||
>;
|
||||
export type CompositeChannelData = Record<string, AnimationChannel | undefined>;
|
||||
export type ChannelData = AnimationChannel | CompositeChannelData;
|
||||
|
||||
export interface ElementAnimations {
|
||||
bindings: ElementAnimationBindingMap;
|
||||
channels: ElementAnimationChannelMap;
|
||||
[propertyPath: AnimationPath]: ChannelData | undefined;
|
||||
}
|
||||
|
||||
export type NormalizedCubicBezier = [number, number, number, number];
|
||||
@@ -181,7 +117,6 @@ export type NormalizedCubicBezier = [number, number, number, number];
|
||||
export interface ScalarGraphChannelTarget {
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
|
||||
@@ -209,8 +144,8 @@ export interface ScalarCurveKeyframePatch {
|
||||
export interface ElementKeyframe {
|
||||
propertyPath: AnimationPath;
|
||||
id: string;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
time: MediaTime;
|
||||
value: ParamValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -190,7 +190,7 @@ export default function BrandPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
|
||||
@@ -130,8 +130,14 @@ function EditorLayout() {
|
||||
direction="vertical"
|
||||
className="size-full gap-[0.18rem]"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("mainContent", sizes[0] ?? panels.mainContent);
|
||||
setPanel("timeline", sizes[1] ?? panels.timeline);
|
||||
setPanel({
|
||||
panel: "mainContent",
|
||||
size: sizes[0] ?? panels.mainContent,
|
||||
});
|
||||
setPanel({
|
||||
panel: "timeline",
|
||||
size: sizes[1] ?? panels.timeline,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
@@ -144,9 +150,12 @@ function EditorLayout() {
|
||||
direction="horizontal"
|
||||
className="size-full gap-[0.19rem] px-3"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("tools", sizes[0] ?? panels.tools);
|
||||
setPanel("preview", sizes[1] ?? panels.preview);
|
||||
setPanel("properties", sizes[2] ?? panels.properties);
|
||||
setPanel({ panel: "tools", size: sizes[0] ?? panels.tools });
|
||||
setPanel({ panel: "preview", size: sizes[1] ?? panels.preview });
|
||||
setPanel({
|
||||
panel: "properties",
|
||||
size: sizes[2] ?? panels.properties,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
|
||||
@@ -57,7 +57,10 @@ export default function PrivacyPage() {
|
||||
content is tracked
|
||||
</li>
|
||||
<li>You can clear local data from your browser at any time</li>
|
||||
<li>We don't sell or share your data with anyone (we don't even have it)</li>
|
||||
<li>
|
||||
We don't sell or share your data with anyone (we don't
|
||||
even have it)
|
||||
</li>
|
||||
</ol>
|
||||
<p className="mt-4">
|
||||
Questions? Email us at{" "}
|
||||
@@ -172,7 +175,7 @@ export default function PrivacyPage() {
|
||||
<a
|
||||
href={SOCIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
@@ -189,7 +192,7 @@ export default function PrivacyPage() {
|
||||
<a
|
||||
href={`${SOCIAL_LINKS.github}/issues`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub repository
|
||||
@@ -205,7 +208,7 @@ export default function PrivacyPage() {
|
||||
<a
|
||||
href={SOCIAL_LINKS.x}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
X (Twitter)
|
||||
|
||||
@@ -51,9 +51,13 @@ export default function TermsPage() {
|
||||
Free for personal and commercial use with no watermarks or
|
||||
restrictions
|
||||
</li>
|
||||
<li>You're responsible for how you use it - don't break the law</li>
|
||||
<li>
|
||||
Service provided "as is" - we can't guarantee perfect uptime
|
||||
You're responsible for how you use it - don't break
|
||||
the law
|
||||
</li>
|
||||
<li>
|
||||
Service provided "as is" - we can't guarantee
|
||||
perfect uptime
|
||||
</li>
|
||||
<li>
|
||||
Open source means you can review our code and self-host if
|
||||
@@ -109,8 +113,8 @@ export default function TermsPage() {
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
You're responsible for how you use OpenCut and the content you create.
|
||||
Don't use it for anything illegal in your jurisdiction.
|
||||
You're responsible for how you use OpenCut and the content you
|
||||
create. Don't use it for anything illegal in your jurisdiction.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -127,8 +131,8 @@ export default function TermsPage() {
|
||||
<h2 className="text-2xl font-semibold">Service</h2>
|
||||
<p>
|
||||
OpenCut does not currently require an account. The service is provided
|
||||
"as is" without warranties. While we strive for reliability, we can't
|
||||
guarantee uninterrupted service.
|
||||
"as is" without warranties. While we strive for
|
||||
reliability, we can't guarantee uninterrupted service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -146,7 +150,7 @@ export default function TermsPage() {
|
||||
<a
|
||||
href={SOCIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
@@ -161,12 +165,12 @@ export default function TermsPage() {
|
||||
OpenCut is provided free of charge. To the extent permitted by law:
|
||||
</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>We're not liable for any loss of data or content</li>
|
||||
<li>We're not liable for any loss of data or content</li>
|
||||
<li>
|
||||
Projects are stored in your browser and may be lost if you clear
|
||||
browser data
|
||||
</li>
|
||||
<li>We're not responsible for how you use the service</li>
|
||||
<li>We're not responsible for how you use the service</li>
|
||||
<li>Our liability is limited to the maximum extent allowed by law</li>
|
||||
</ul>
|
||||
<p>
|
||||
@@ -180,7 +184,7 @@ export default function TermsPage() {
|
||||
<h2 className="text-2xl font-semibold">Service Changes</h2>
|
||||
<p>We may update OpenCut and these terms:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>We'll notify you of significant changes to these terms</li>
|
||||
<li>We'll notify you of significant changes to these terms</li>
|
||||
<li>Continued use means you accept any updates</li>
|
||||
<li>You can always self-host an older version if you prefer</li>
|
||||
<li>Major changes will be discussed with the community on GitHub</li>
|
||||
@@ -203,7 +207,7 @@ export default function TermsPage() {
|
||||
<a
|
||||
href={`${SOCIAL_LINKS.github}/issues`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub repository
|
||||
@@ -219,7 +223,7 @@ export default function TermsPage() {
|
||||
<a
|
||||
href={SOCIAL_LINKS.x}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
X (Twitter)
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ElementsClipboardHandler = {
|
||||
};
|
||||
},
|
||||
|
||||
paste(entry, context) {
|
||||
paste({ entry, context }) {
|
||||
if (entry.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@ export function buildPasteClipboardCommand({
|
||||
const handler = clipboardHandlers[entry.type] as ClipboardHandler<
|
||||
typeof entry.type
|
||||
>;
|
||||
return handler.paste(entry as never, context);
|
||||
return handler.paste({ entry: entry as never, context });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getKeyframeById } from "@/animation";
|
||||
import { getChannelEntriesFromData } from "@/animation/channel-data";
|
||||
import { isScalarChannel } from "@/animation/interpolation";
|
||||
import type { SelectedKeyframeRef } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { PasteKeyframesCommand } from "@/commands/timeline";
|
||||
@@ -7,6 +9,7 @@ import type {
|
||||
KeyframeClipboardCurvePatch,
|
||||
KeyframeClipboardItem,
|
||||
} from "../types";
|
||||
import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm";
|
||||
|
||||
function resolveSingleSourceElement({
|
||||
selectedKeyframes,
|
||||
@@ -40,14 +43,13 @@ function getCurvePatches({
|
||||
propertyPath: KeyframeClipboardItem["propertyPath"];
|
||||
keyframeId: string;
|
||||
}): KeyframeClipboardCurvePatch[] {
|
||||
const binding = element.animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
const data = element.animations?.[propertyPath];
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return binding.components.flatMap((component) => {
|
||||
const channel = element.animations?.channels[component.channelId];
|
||||
if (channel?.kind !== "scalar") {
|
||||
return getChannelEntriesFromData({ data }).flatMap(([componentKey, channel]) => {
|
||||
if (!channel || !isScalarChannel(channel)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -60,7 +62,7 @@ function getCurvePatches({
|
||||
|
||||
return [
|
||||
{
|
||||
componentKey: component.key,
|
||||
componentKey,
|
||||
patch: {
|
||||
leftHandle: keyframe.leftHandle ?? null,
|
||||
rightHandle: keyframe.rightHandle ?? null,
|
||||
@@ -80,7 +82,7 @@ function buildClipboardItem({
|
||||
element: TimelineElement;
|
||||
propertyPath: KeyframeClipboardItem["propertyPath"];
|
||||
keyframeId: string;
|
||||
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null {
|
||||
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null {
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
@@ -136,10 +138,11 @@ export const KeyframesClipboardHandler = {
|
||||
}
|
||||
|
||||
const minTime = Math.min(...rawItems.map((item) => item.time));
|
||||
const minTimeMedia = roundMediaTime({ time: minTime });
|
||||
const items = rawItems
|
||||
.map(({ time, ...item }) => ({
|
||||
...item,
|
||||
timeOffset: time - minTime,
|
||||
timeOffset: subMediaTime({ a: time, b: minTimeMedia }),
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
@@ -154,7 +157,10 @@ export const KeyframesClipboardHandler = {
|
||||
};
|
||||
},
|
||||
|
||||
paste(entry, { selectedElements, time }) {
|
||||
paste({
|
||||
entry,
|
||||
context: { selectedElements, time },
|
||||
}) {
|
||||
const targetElement = selectedElements[0];
|
||||
if (!targetElement || entry.items.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -2,16 +2,17 @@ import type { EditorCore } from "@/core";
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
ScalarCurveKeyframePatch,
|
||||
SelectedKeyframeRef,
|
||||
} from "@/animation/types";
|
||||
import type { ParamValue } from "@/params";
|
||||
import type { Command } from "@/commands/base-command";
|
||||
import type {
|
||||
CreateTimelineElement,
|
||||
ElementRef,
|
||||
TrackType,
|
||||
} from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export interface ElementClipboardItem {
|
||||
trackId: string;
|
||||
@@ -26,8 +27,8 @@ export interface KeyframeClipboardCurvePatch {
|
||||
|
||||
export interface KeyframeClipboardItem {
|
||||
propertyPath: AnimationPath;
|
||||
timeOffset: number;
|
||||
value: AnimationValue;
|
||||
timeOffset: MediaTime;
|
||||
value: ParamValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
curvePatches: KeyframeClipboardCurvePatch[];
|
||||
}
|
||||
@@ -61,17 +62,17 @@ export interface PasteContext {
|
||||
editor: EditorCore;
|
||||
selectedElements: ElementRef[];
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
}
|
||||
|
||||
export interface ClipboardHandler<TType extends ClipboardEntryType> {
|
||||
type: TType;
|
||||
canCopy(context: CopyContext): boolean;
|
||||
copy(context: CopyContext): ClipboardEntryByType[TType] | null;
|
||||
paste(
|
||||
entry: ClipboardEntryByType[TType],
|
||||
context: PasteContext,
|
||||
): Command | null;
|
||||
paste(args: {
|
||||
entry: ClipboardEntryByType[TType];
|
||||
context: PasteContext;
|
||||
}): Command | null;
|
||||
}
|
||||
|
||||
export type ClipboardHandlerMap = {
|
||||
|
||||
@@ -16,14 +16,22 @@ export class AddMediaAssetCommand extends Command {
|
||||
private previousProjectFps: FrameRate | null = null;
|
||||
private appliedProjectFps: FrameRate | null = null;
|
||||
|
||||
constructor(
|
||||
private projectId: string,
|
||||
private asset: Omit<MediaAsset, "id">,
|
||||
) {
|
||||
constructor({
|
||||
projectId,
|
||||
asset,
|
||||
}: {
|
||||
projectId: string;
|
||||
asset: Omit<MediaAsset, "id">;
|
||||
}) {
|
||||
super();
|
||||
this.projectId = projectId;
|
||||
this.asset = asset;
|
||||
this.assetId = generateUUID();
|
||||
}
|
||||
|
||||
private projectId: string;
|
||||
private asset: Omit<MediaAsset, "id">;
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedAssets = [...editor.media.getAssets()];
|
||||
@@ -117,7 +125,14 @@ export class AddMediaAssetCommand extends Command {
|
||||
|
||||
const activeProject = editor.project.getActiveOrNull();
|
||||
if (!activeProject) return;
|
||||
if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
|
||||
if (
|
||||
!this.appliedProjectFps ||
|
||||
!frameRatesEqual({
|
||||
a: activeProject.settings.fps,
|
||||
b: this.appliedProjectFps,
|
||||
})
|
||||
)
|
||||
return;
|
||||
|
||||
const highestRemainingVideoFps = getHighestImportedVideoFps({
|
||||
mediaAssets: editor.media.getAssets(),
|
||||
|
||||
@@ -13,13 +13,21 @@ export class RemoveMediaAssetCommand extends Command {
|
||||
private savedTracks: SceneTracks | null = null;
|
||||
private removedAsset: MediaAsset | null = null;
|
||||
|
||||
constructor(
|
||||
private projectId: string,
|
||||
private assetId: string,
|
||||
) {
|
||||
constructor({
|
||||
projectId,
|
||||
assetId,
|
||||
}: {
|
||||
projectId: string;
|
||||
assetId: string;
|
||||
}) {
|
||||
super();
|
||||
this.projectId = projectId;
|
||||
this.assetId = assetId;
|
||||
}
|
||||
|
||||
private projectId: string;
|
||||
private assetId: string;
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const assets = editor.media.getAssets();
|
||||
|
||||
@@ -7,13 +7,21 @@ export class CreateSceneCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private createdScene: TScene | null = null;
|
||||
|
||||
constructor(
|
||||
private name: string,
|
||||
private isMain: boolean = false,
|
||||
) {
|
||||
constructor({
|
||||
name,
|
||||
isMain = false,
|
||||
}: {
|
||||
name: string;
|
||||
isMain?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.name = name;
|
||||
this.isMain = isMain;
|
||||
}
|
||||
|
||||
private name: string;
|
||||
private isMain: boolean;
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedScenes = [...editor.scenes.getScenes()];
|
||||
|
||||
@@ -3,17 +3,26 @@ import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/timeline";
|
||||
import { updateSceneInArray } from "@/timeline/scenes";
|
||||
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class MoveBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
|
||||
constructor(
|
||||
private fromTime: number,
|
||||
private toTime: number,
|
||||
) {
|
||||
constructor({
|
||||
fromTime,
|
||||
toTime,
|
||||
}: {
|
||||
fromTime: MediaTime;
|
||||
toTime: MediaTime;
|
||||
}) {
|
||||
super();
|
||||
this.fromTime = fromTime;
|
||||
this.toTime = toTime;
|
||||
}
|
||||
|
||||
private fromTime: MediaTime;
|
||||
private toTime: MediaTime;
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
getFrameTime,
|
||||
removeBookmarkFromArray,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||
|
||||
export class RemoveBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private frameTime: number = 0;
|
||||
private frameTime: MediaTime = ZERO_MEDIA_TIME;
|
||||
|
||||
constructor(private time: number) {
|
||||
constructor(private time: MediaTime) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,21 @@ export class RenameSceneCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private previousName: string | null = null;
|
||||
|
||||
constructor(
|
||||
private sceneId: string,
|
||||
private newName: string,
|
||||
) {
|
||||
constructor({
|
||||
sceneId,
|
||||
newName,
|
||||
}: {
|
||||
sceneId: string;
|
||||
newName: string;
|
||||
}) {
|
||||
super();
|
||||
this.sceneId = sceneId;
|
||||
this.newName = newName;
|
||||
}
|
||||
|
||||
private sceneId: string;
|
||||
private newName: string;
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const scenes = editor.scenes.getScenes();
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
getFrameTime,
|
||||
toggleBookmarkInArray,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||
|
||||
export class ToggleBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private frameTime: number = 0;
|
||||
private frameTime: MediaTime = ZERO_MEDIA_TIME;
|
||||
|
||||
constructor(private time: number) {
|
||||
constructor(private time: MediaTime) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,26 @@ import {
|
||||
getFrameTime,
|
||||
updateBookmarkInArray,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class UpdateBookmarkCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
|
||||
constructor(
|
||||
private time: number,
|
||||
private updates: Partial<Omit<Bookmark, "time">>,
|
||||
) {
|
||||
constructor({
|
||||
time,
|
||||
updates,
|
||||
}: {
|
||||
time: MediaTime;
|
||||
updates: Partial<Omit<Bookmark, "time">>;
|
||||
}) {
|
||||
super();
|
||||
this.time = time;
|
||||
this.updates = updates;
|
||||
}
|
||||
|
||||
private time: MediaTime;
|
||||
private updates: Partial<Omit<Bookmark, "time">>;
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
resolveAnimationTarget,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
@@ -9,7 +8,15 @@ import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { KeyframeClipboardItem } from "@/clipboard";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
addMediaTime,
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
minMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
function pasteKeyframesIntoElement({
|
||||
element,
|
||||
@@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({
|
||||
clipboardItems,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
clipboardItems: KeyframeClipboardItem[];
|
||||
}): TimelineElement {
|
||||
let nextElement = element;
|
||||
@@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyframeTime = Math.max(
|
||||
0,
|
||||
Math.min(time + item.timeOffset, nextElement.duration),
|
||||
);
|
||||
const keyframeTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({
|
||||
a: addMediaTime({ a: time, b: item.timeOffset }),
|
||||
b: nextElement.duration,
|
||||
}),
|
||||
});
|
||||
const nextAnimations = upsertPathKeyframe({
|
||||
animations: nextElement.animations,
|
||||
propertyPath: item.propertyPath,
|
||||
@@ -42,8 +52,7 @@ function pasteKeyframesIntoElement({
|
||||
value: item.value,
|
||||
interpolation: item.interpolation,
|
||||
keyframeId: generateUUID(),
|
||||
kind: target.kind,
|
||||
defaultInterpolation: target.defaultInterpolation,
|
||||
channelLayout: target.channelLayout,
|
||||
coerceValue: target.coerceValue,
|
||||
});
|
||||
const pastedKeyframe = getKeyframeAtTime({
|
||||
@@ -79,7 +88,7 @@ export class PasteKeyframesCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly time: number;
|
||||
private readonly time: MediaTime;
|
||||
private readonly clipboardItems: KeyframeClipboardItem[];
|
||||
|
||||
constructor({
|
||||
@@ -90,7 +99,7 @@ export class PasteKeyframesCommand extends Command {
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
clipboardItems: KeyframeClipboardItem[];
|
||||
}) {
|
||||
super();
|
||||
|
||||
@@ -13,18 +13,25 @@ import {
|
||||
enforceMainTrackStart,
|
||||
} from "@/timeline/placement";
|
||||
import { cloneAnimations } from "@/animation";
|
||||
import {
|
||||
addMediaTime,
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
subMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private pastedElements: { trackId: string; elementId: string }[] = [];
|
||||
private readonly time: number;
|
||||
private readonly time: MediaTime;
|
||||
private readonly clipboardItems: ElementClipboardItem[];
|
||||
|
||||
constructor({
|
||||
time,
|
||||
clipboardItems,
|
||||
}: {
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
clipboardItems: ElementClipboardItem[];
|
||||
}) {
|
||||
super();
|
||||
@@ -39,8 +46,12 @@ export class PasteCommand extends Command {
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
this.pastedElements = [];
|
||||
|
||||
const minStart = Math.min(
|
||||
...this.clipboardItems.map((item) => item.element.startTime),
|
||||
const minStart = this.clipboardItems.reduce(
|
||||
(earliestStartTime, item) =>
|
||||
item.element.startTime < earliestStartTime
|
||||
? item.element.startTime
|
||||
: earliestStartTime,
|
||||
this.clipboardItems[0].element.startTime,
|
||||
);
|
||||
|
||||
let updatedTracks = this.savedState;
|
||||
@@ -97,12 +108,18 @@ export class PasteCommand extends Command {
|
||||
targetTrackId: targetTrack.id,
|
||||
requestedStartTime: earliestElement.startTime,
|
||||
});
|
||||
const delta = adjustedEarliestStartTime - earliestElement.startTime;
|
||||
const delta = subMediaTime({
|
||||
a: adjustedEarliestStartTime,
|
||||
b: earliestElement.startTime,
|
||||
});
|
||||
|
||||
if (delta !== 0) {
|
||||
if (delta !== ZERO_MEDIA_TIME) {
|
||||
elementsForPlacement = elementsToAdd.map((element) => ({
|
||||
...element,
|
||||
startTime: Math.max(0, element.startTime + delta),
|
||||
startTime: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: addMediaTime({ a: element.startTime, b: delta }),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -168,14 +185,20 @@ function buildPastedElements({
|
||||
time,
|
||||
}: {
|
||||
items: ElementClipboardItem[];
|
||||
minStart: number;
|
||||
time: number;
|
||||
minStart: MediaTime;
|
||||
time: MediaTime;
|
||||
}): TimelineElement[] {
|
||||
const elementsToAdd: TimelineElement[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const relativeOffset = item.element.startTime - minStart;
|
||||
const startTime = Math.max(0, time + relativeOffset);
|
||||
const relativeOffset = subMediaTime({
|
||||
a: item.element.startTime,
|
||||
b: minStart,
|
||||
});
|
||||
const startTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: addMediaTime({ a: time, b: relativeOffset }),
|
||||
});
|
||||
const newElementId = generateUUID();
|
||||
|
||||
elementsToAdd.push({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
|
||||
import { cloneAnimations } from "@/animation";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
interface DuplicateElementsParams {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
@@ -119,7 +120,7 @@ function buildDuplicateElement({
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
id: string;
|
||||
startTime: number;
|
||||
startTime: MediaTime;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
...element,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveTrackPlacement,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/timeline/placement";
|
||||
import { roundMediaTime } from "@/wasm";
|
||||
|
||||
type InsertElementPlacement =
|
||||
| { mode: "explicit"; trackId: string }
|
||||
@@ -188,7 +189,7 @@ export class InsertElementCommand extends Command {
|
||||
}
|
||||
}
|
||||
|
||||
if (element.type === "text" && !element.content) {
|
||||
if (element.type === "text" && !element.params.content) {
|
||||
console.error("Text element must have content");
|
||||
return false;
|
||||
}
|
||||
@@ -266,7 +267,12 @@ export class InsertElementCommand extends Command {
|
||||
placementResult.kind === "existingTrack"
|
||||
? {
|
||||
...element,
|
||||
startTime: placementResult.adjustedStartTime ?? element.startTime,
|
||||
startTime:
|
||||
placementResult.adjustedStartTime !== undefined
|
||||
? roundMediaTime({
|
||||
time: placementResult.adjustedStartTime,
|
||||
})
|
||||
: element.startTime,
|
||||
}
|
||||
: element;
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ import { EditorCore } from "@/core";
|
||||
import {
|
||||
hasKeyframesForPath,
|
||||
removeElementKeyframe,
|
||||
resolveAnimationTarget,
|
||||
} from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { AnimationPath, AnimationValue } from "@/animation/types";
|
||||
import type { AnimationPath } from "@/animation/types";
|
||||
import type { ParamValue } from "@/params";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
|
||||
function removeKeyframeAndPersist({
|
||||
element,
|
||||
@@ -18,7 +19,7 @@ function removeKeyframeAndPersist({
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
valueAtPlayhead: AnimationValue | null;
|
||||
valueAtPlayhead: ParamValue | null;
|
||||
}): TimelineElement {
|
||||
const target = resolveAnimationTarget({ element, path: propertyPath });
|
||||
if (!target) {
|
||||
@@ -52,7 +53,7 @@ export class RemoveKeyframeCommand extends Command {
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly keyframeId: string;
|
||||
private readonly valueAtPlayhead: AnimationValue | null;
|
||||
private readonly valueAtPlayhead: ParamValue | null;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
@@ -65,7 +66,7 @@ export class RemoveKeyframeCommand extends Command {
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
valueAtPlayhead: AnimationValue | null;
|
||||
valueAtPlayhead: ParamValue | null;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation";
|
||||
import { retimeElementKeyframe } from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { AnimationPath } from "@/animation/types";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import {
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
minMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
|
||||
export class RetimeKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
@@ -11,7 +18,7 @@ export class RetimeKeyframeCommand extends Command {
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly keyframeId: string;
|
||||
private readonly nextTime: number;
|
||||
private readonly nextTime: MediaTime;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
@@ -24,7 +31,7 @@ export class RetimeKeyframeCommand extends Command {
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
nextTime: number;
|
||||
nextTime: MediaTime;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
@@ -47,7 +54,10 @@ export class RetimeKeyframeCommand extends Command {
|
||||
return element;
|
||||
}
|
||||
|
||||
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
|
||||
const boundedTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({ a: this.nextTime, b: element.duration }),
|
||||
});
|
||||
return {
|
||||
...element,
|
||||
animations: retimeElementKeyframe({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
resolveAnimationTarget,
|
||||
updateScalarKeyframeCurve,
|
||||
} from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ScalarCurveKeyframePatch,
|
||||
|
||||
@@ -2,13 +2,19 @@ import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import {
|
||||
buildEffectParamPath,
|
||||
resolveAnimationTarget,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type { AnimationInterpolation } from "@/animation/types";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import {
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
minMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
@@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
private readonly paramKey: string;
|
||||
private readonly time: number;
|
||||
private readonly time: MediaTime;
|
||||
private readonly value: number | string | boolean;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
@@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
value: number | string | boolean;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
@@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
const boundedTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({ a: this.time, b: element.duration }),
|
||||
});
|
||||
const propertyPath = buildEffectParamPath({
|
||||
effectId: this.effectId,
|
||||
paramKey: this.paramKey,
|
||||
@@ -81,8 +90,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
value: this.value,
|
||||
interpolation: this.interpolation,
|
||||
keyframeId: this.keyframeId,
|
||||
kind: target.kind,
|
||||
defaultInterpolation: target.defaultInterpolation,
|
||||
channelLayout: target.channelLayout,
|
||||
coerceValue: target.coerceValue,
|
||||
});
|
||||
return { ...element, animations };
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation";
|
||||
import { upsertPathKeyframe } from "@/animation";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type {
|
||||
AnimationPath,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
} from "@/animation/types";
|
||||
import type { ParamValue } from "@/params";
|
||||
import {
|
||||
type MediaTime,
|
||||
maxMediaTime,
|
||||
minMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class UpsertKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly time: number;
|
||||
private readonly value: AnimationValue;
|
||||
private readonly time: MediaTime;
|
||||
private readonly value: ParamValue;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
private readonly keyframeId: string | undefined;
|
||||
|
||||
@@ -31,8 +38,8 @@ export class UpsertKeyframeCommand extends Command {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
time: MediaTime;
|
||||
value: ParamValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
keyframeId?: string;
|
||||
}) {
|
||||
@@ -63,7 +70,10 @@ export class UpsertKeyframeCommand extends Command {
|
||||
return element;
|
||||
}
|
||||
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
const boundedTime = maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: minMediaTime({ a: this.time, b: element.duration }),
|
||||
});
|
||||
return {
|
||||
...element,
|
||||
animations: upsertPathKeyframe({
|
||||
@@ -73,8 +83,7 @@ export class UpsertKeyframeCommand extends Command {
|
||||
value: this.value,
|
||||
interpolation: this.interpolation,
|
||||
keyframeId: this.keyframeId,
|
||||
kind: target.kind,
|
||||
defaultInterpolation: target.defaultInterpolation,
|
||||
channelLayout: target.channelLayout,
|
||||
coerceValue: target.coerceValue,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import {
|
||||
getCustomMaskClosedStateAfterPointRemoval,
|
||||
removeCustomMaskPoints,
|
||||
} from "@/masks/custom-path";
|
||||
import type { CustomMask } from "@/masks/types";
|
||||
getFreeformPathClosedStateAfterPointRemoval,
|
||||
removeFreeformPathPoints,
|
||||
} from "@/masks/freeform/path";
|
||||
import type { FreeformPathMask } from "@/masks/types";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/timeline";
|
||||
|
||||
function deletePointsFromCustomMask({
|
||||
function deletePointsFromFreeformPathMask({
|
||||
mask,
|
||||
pointIds,
|
||||
}: {
|
||||
mask: CustomMask;
|
||||
mask: FreeformPathMask;
|
||||
pointIds: string[];
|
||||
}): CustomMask {
|
||||
}): FreeformPathMask {
|
||||
const points = mask.params.path;
|
||||
const nextPoints = removeCustomMaskPoints({ points, pointIds });
|
||||
const nextPoints = removeFreeformPathPoints({ points, pointIds });
|
||||
if (nextPoints.length === points.length) {
|
||||
return mask;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ function deletePointsFromCustomMask({
|
||||
params: {
|
||||
...mask.params,
|
||||
path: nextPoints,
|
||||
closed: getCustomMaskClosedStateAfterPointRemoval({
|
||||
closed: getFreeformPathClosedStateAfterPointRemoval({
|
||||
wasClosed: mask.params.closed,
|
||||
remainingPointCount: nextPoints.length,
|
||||
}),
|
||||
@@ -46,11 +46,11 @@ function deletePointsFromElementMask({
|
||||
const currentMasks = element.masks ?? [];
|
||||
let didDeletePoints = false;
|
||||
const nextMasks = currentMasks.map((mask) => {
|
||||
if (mask.id !== maskId || mask.type !== "custom") {
|
||||
if (mask.id !== maskId || mask.type !== "freeform") {
|
||||
return mask;
|
||||
}
|
||||
|
||||
const nextMask = deletePointsFromCustomMask({
|
||||
const nextMask = deletePointsFromFreeformPathMask({
|
||||
mask,
|
||||
pointIds,
|
||||
});
|
||||
@@ -64,7 +64,7 @@ function deletePointsFromElementMask({
|
||||
};
|
||||
}
|
||||
|
||||
export class DeleteCustomMaskPointsCommand extends Command {
|
||||
export class DeleteFreeformPathMaskPointsCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
@@ -100,8 +100,9 @@ export class DeleteCustomMaskPointsCommand extends Command {
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) => {
|
||||
if (!isMaskableElement(element)) return element;
|
||||
const result = deletePointsFromElementMask({
|
||||
element: element as MaskableElement,
|
||||
element,
|
||||
maskId: this.maskId,
|
||||
pointIds: this.pointIds,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
|
||||
export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
|
||||
export { DeleteFreeformPathMaskPointsCommand } from "./delete-custom-mask-points";
|
||||
export { InsertFreeformPathMaskPointCommand } from "./insert-custom-mask-point";
|
||||
export { RemoveMaskCommand } from "./remove-mask";
|
||||
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom";
|
||||
import { insertPointOnFreeformSegment } from "@/masks/freeform/definition";
|
||||
import type { ElementBounds } from "@/preview/element-bounds";
|
||||
import type { CustomMask } from "@/masks/types";
|
||||
import type { FreeformPathMask } from "@/masks/types";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/timeline";
|
||||
|
||||
function insertPointIntoCustomMask({
|
||||
function insertPointIntoFreeformPathMask({
|
||||
mask,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
mask: CustomMask;
|
||||
mask: FreeformPathMask;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): { mask: CustomMask; insertedPointId: string | null } {
|
||||
const result = insertPointOnCustomMaskSegment({
|
||||
}): { mask: FreeformPathMask; insertedPointId: string | null } {
|
||||
const result = insertPointOnFreeformSegment({
|
||||
params: mask.params,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
@@ -61,11 +61,11 @@ function insertPointIntoElementMask({
|
||||
let didInsertPoint = false;
|
||||
|
||||
const nextMasks = currentMasks.map((mask) => {
|
||||
if (mask.id !== maskId || mask.type !== "custom") {
|
||||
if (mask.id !== maskId || mask.type !== "freeform") {
|
||||
return mask;
|
||||
}
|
||||
|
||||
const result = insertPointIntoCustomMask({
|
||||
const result = insertPointIntoFreeformPathMask({
|
||||
mask,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
@@ -85,7 +85,7 @@ function insertPointIntoElementMask({
|
||||
};
|
||||
}
|
||||
|
||||
export class InsertCustomMaskPointCommand extends Command {
|
||||
export class InsertFreeformPathMaskPointCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
@@ -130,8 +130,9 @@ export class InsertCustomMaskPointCommand extends Command {
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) => {
|
||||
if (!isMaskableElement(element)) return element;
|
||||
const result = insertPointIntoElementMask({
|
||||
element: element as MaskableElement,
|
||||
element,
|
||||
maskId: this.maskId,
|
||||
segmentIndex: this.segmentIndex,
|
||||
canvasPoint: this.canvasPoint,
|
||||
|
||||
@@ -45,11 +45,13 @@ export class RemoveMaskCommand extends Command {
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) =>
|
||||
removeMaskFromElement({
|
||||
element: element as MaskableElement,
|
||||
update: (element) => {
|
||||
if (!isMaskableElement(element)) return element;
|
||||
return removeMaskFromElement({
|
||||
element,
|
||||
maskId: this.maskId,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
@@ -56,11 +56,13 @@ export class ToggleMaskInvertedCommand extends Command {
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) =>
|
||||
toggleMaskInvertedOnElement({
|
||||
element: element as MaskableElement,
|
||||
update: (element) => {
|
||||
if (!isMaskableElement(element)) return element;
|
||||
return toggleMaskInvertedOnElement({
|
||||
element,
|
||||
maskId: this.maskId,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
@@ -9,12 +9,18 @@ import { EditorCore } from "@/core";
|
||||
import { isRetimableElement } from "@/timeline";
|
||||
import { splitAnimationsAtTime } from "@/animation";
|
||||
import { getSourceSpanAtClipTime } from "@/retime";
|
||||
import {
|
||||
addMediaTime,
|
||||
type MediaTime,
|
||||
roundMediaTime,
|
||||
subMediaTime,
|
||||
} from "@/wasm";
|
||||
|
||||
export class SplitElementsCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly splitTime: number;
|
||||
private readonly splitTime: MediaTime;
|
||||
private readonly retainSide: "both" | "left" | "right";
|
||||
|
||||
constructor({
|
||||
@@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command {
|
||||
retainSide = "both",
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
splitTime: MediaTime;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}) {
|
||||
super();
|
||||
@@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command {
|
||||
return [element];
|
||||
}
|
||||
|
||||
const relativeTime = this.splitTime - element.startTime;
|
||||
const relativeTime = subMediaTime({
|
||||
a: this.splitTime,
|
||||
b: element.startTime,
|
||||
});
|
||||
const leftVisibleDuration = relativeTime;
|
||||
const rightVisibleDuration = element.duration - relativeTime;
|
||||
const rightVisibleDuration = subMediaTime({
|
||||
a: element.duration,
|
||||
b: relativeTime,
|
||||
});
|
||||
const retimeRef = isRetimableElement(element)
|
||||
? element.retime
|
||||
: undefined;
|
||||
const leftSourceSpan = getSourceSpanAtClipTime({
|
||||
clipTime: leftVisibleDuration,
|
||||
retime: retimeRef,
|
||||
// Snap the source-side split point exactly once and derive the right
|
||||
// half from it. Independently rounding both spans (left and total)
|
||||
// would let a 1-tick rounding error desynchronise them, breaking the
|
||||
// invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`.
|
||||
// See the same discipline in `compute-resize.ts` (snap-once comment).
|
||||
const leftSourceSpan = roundMediaTime({
|
||||
time: getSourceSpanAtClipTime({
|
||||
clipTime: leftVisibleDuration,
|
||||
retime: retimeRef,
|
||||
}),
|
||||
});
|
||||
const totalSourceSpan = getSourceSpanAtClipTime({
|
||||
clipTime: element.duration,
|
||||
retime: retimeRef,
|
||||
const totalSourceSpan = roundMediaTime({
|
||||
time: getSourceSpanAtClipTime({
|
||||
clipTime: element.duration,
|
||||
retime: retimeRef,
|
||||
}),
|
||||
});
|
||||
const rightSourceSpan = subMediaTime({
|
||||
a: totalSourceSpan,
|
||||
b: leftSourceSpan,
|
||||
});
|
||||
const rightSourceSpan = totalSourceSpan - leftSourceSpan;
|
||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||
animations: element.animations,
|
||||
splitTime: relativeTime,
|
||||
@@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command {
|
||||
});
|
||||
let splitResult: TimelineElement[];
|
||||
|
||||
const leftTrimEnd = addMediaTime({
|
||||
a: element.trimEnd,
|
||||
b: rightSourceSpan,
|
||||
});
|
||||
const rightTrimStart = addMediaTime({
|
||||
a: element.trimStart,
|
||||
b: leftSourceSpan,
|
||||
});
|
||||
|
||||
if (this.retainSide === "left") {
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
trimEnd: leftTrimEnd,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
@@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command {
|
||||
id: newId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
trimStart: rightTrimStart,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// "both" - split into two pieces
|
||||
const secondElementId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
@@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command {
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
trimEnd: leftTrimEnd,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
@@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command {
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
trimStart: rightTrimStart,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
|
||||
@@ -11,14 +11,22 @@ export class AddTrackCommand extends Command {
|
||||
private trackId: string;
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
constructor(
|
||||
private type: TrackType,
|
||||
private index?: number,
|
||||
) {
|
||||
constructor({
|
||||
type,
|
||||
index,
|
||||
}: {
|
||||
type: TrackType;
|
||||
index?: number;
|
||||
}) {
|
||||
super();
|
||||
this.type = type;
|
||||
this.index = index;
|
||||
this.trackId = generateUUID();
|
||||
}
|
||||
|
||||
private type: TrackType;
|
||||
private index?: number;
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
@@ -3,13 +3,21 @@ import type { SceneTracks } from "@/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class TracksSnapshotCommand extends Command {
|
||||
constructor(
|
||||
private before: SceneTracks,
|
||||
private after: SceneTracks,
|
||||
) {
|
||||
constructor({
|
||||
before,
|
||||
after,
|
||||
}: {
|
||||
before: SceneTracks;
|
||||
after: SceneTracks;
|
||||
}) {
|
||||
super();
|
||||
this.before = before;
|
||||
this.after = after;
|
||||
}
|
||||
|
||||
private before: SceneTracks;
|
||||
private after: SceneTracks;
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
EditorCore.getInstance().timeline.updateTracks(this.after);
|
||||
return undefined;
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
|
||||
import {
|
||||
formatTimecode,
|
||||
type FrameRate,
|
||||
type TimeCodeFormat,
|
||||
} from "opencut-wasm";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
parseMediaTimecode,
|
||||
snapSeekMediaTime,
|
||||
type MediaTime,
|
||||
} from "@/wasm";
|
||||
|
||||
interface EditableTimecodeProps {
|
||||
time: number;
|
||||
duration: number;
|
||||
time: MediaTime;
|
||||
duration: MediaTime;
|
||||
format?: TimeCodeFormat;
|
||||
fps: FrameRate;
|
||||
onTimeChange?: ({ time }: { time: number }) => void;
|
||||
onTimeChange?: ({ time }: { time: MediaTime }) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -46,7 +55,11 @@ export function EditableTimecode({
|
||||
};
|
||||
|
||||
const applyEdit = () => {
|
||||
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
|
||||
const parsedTime = parseMediaTimecode({
|
||||
timeCode: inputValue,
|
||||
format,
|
||||
fps,
|
||||
});
|
||||
|
||||
if (parsedTime == null) {
|
||||
setHasError(true);
|
||||
@@ -54,7 +67,7 @@ export function EditableTimecode({
|
||||
}
|
||||
|
||||
const clampedTime = duration
|
||||
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
|
||||
? snapSeekMediaTime({ time: parsedTime, duration, fps })
|
||||
: parsedTime;
|
||||
|
||||
onTimeChange?.({ time: clampedTime });
|
||||
|
||||
@@ -93,7 +93,7 @@ interface AssetsPanelStore {
|
||||
setMediaViewMode: (mode: MediaViewMode) => void;
|
||||
mediaSortBy: MediaSortKey;
|
||||
mediaSortOrder: MediaSortOrder;
|
||||
setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void;
|
||||
setMediaSort: (args: { key: MediaSortKey; order: MediaSortOrder }) => void;
|
||||
}
|
||||
|
||||
export const useAssetsPanelStore = create<AssetsPanelStore>()(
|
||||
@@ -109,7 +109,7 @@ export const useAssetsPanelStore = create<AssetsPanelStore>()(
|
||||
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||
mediaSortBy: "name",
|
||||
mediaSortOrder: "asc",
|
||||
setMediaSort: (key, order) =>
|
||||
setMediaSort: ({ key, order }) =>
|
||||
set({ mediaSortBy: key, mediaSortOrder: order }),
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -1,240 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export interface DraggableItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: TimelineDragData;
|
||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||
onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
shouldShowPlusOnDrag?: boolean;
|
||||
shouldShowLabel?: boolean;
|
||||
isRounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
onDragStart,
|
||||
onAddToTimeline,
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
containerClassName,
|
||||
shouldShowPlusOnDrag = true,
|
||||
shouldShowLabel = true,
|
||||
isRounded = true,
|
||||
variant = "card",
|
||||
isDraggable = true,
|
||||
}: DraggableItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
emptyImg.src =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("dragover", handleDragOver);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const handleDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
editor.timeline.dragSource.begin({
|
||||
dataTransfer: event.dataTransfer,
|
||||
dragData,
|
||||
});
|
||||
|
||||
setDragPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.({ e: event });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
editor.timeline.dragSource.end();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative", containerClassName ?? "w-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-auto w-full cursor-default flex-col gap-1 p-",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-accent relative overflow-hidden",
|
||||
isRounded && "rounded-sm",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{shouldShowLabel && (
|
||||
<span
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
title={name}
|
||||
>
|
||||
<span className="sr-only">{name}</span>
|
||||
<span aria-hidden="true">
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative w-full", containerClassName)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="size-6 shrink-0 overflow-hidden rounded-sm">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggable &&
|
||||
isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-9999"
|
||||
style={{
|
||||
left: dragPosition.x - 40,
|
||||
top: dragPosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
|
||||
>
|
||||
<div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{shouldShowPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusButton({
|
||||
className,
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
tooltipText?: string;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltipText) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import { cn } from "@/utils/ui";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export interface DraggableItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: TimelineDragData;
|
||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||
onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
shouldShowPlusOnDrag?: boolean;
|
||||
shouldShowLabel?: boolean;
|
||||
isRounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
onDragStart,
|
||||
onAddToTimeline,
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
containerClassName,
|
||||
shouldShowPlusOnDrag = true,
|
||||
shouldShowLabel = true,
|
||||
isRounded = true,
|
||||
variant = "card",
|
||||
isDraggable = true,
|
||||
}: DraggableItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
emptyImg.src =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("dragover", handleDragOver);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const handleDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
editor.timeline.dragSource.begin({
|
||||
dataTransfer: event.dataTransfer,
|
||||
dragData,
|
||||
});
|
||||
|
||||
setDragPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.({ e: event });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
editor.timeline.dragSource.end();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative", containerClassName ?? "w-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-auto w-full cursor-default flex-col gap-1 p-",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-accent relative overflow-hidden",
|
||||
isRounded && "rounded-sm",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{shouldShowLabel && (
|
||||
<span
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
title={name}
|
||||
>
|
||||
<span className="sr-only">{name}</span>
|
||||
<span aria-hidden="true">
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative w-full", containerClassName)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="size-6 shrink-0 overflow-hidden rounded-sm">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggable &&
|
||||
isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-9999"
|
||||
style={{
|
||||
left: dragPosition.x - 40,
|
||||
top: dragPosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
|
||||
>
|
||||
<div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{shouldShowPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusButton({
|
||||
className,
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
tooltipText?: string;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltipText) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useFileUpload } from "@/media/use-file-upload";
|
||||
import { invokeAction } from "@/actions";
|
||||
@@ -139,9 +139,12 @@ export function MediaView() {
|
||||
|
||||
const handleSort = ({ key }: { key: MediaSortKey }) => {
|
||||
if (mediaSortBy === key) {
|
||||
setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc");
|
||||
setMediaSort({
|
||||
key,
|
||||
order: mediaSortOrder === "asc" ? "desc" : "asc",
|
||||
});
|
||||
} else {
|
||||
setMediaSort(key, "asc");
|
||||
setMediaSort({ key, order: "asc" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -255,11 +258,11 @@ function MediaAssetDraggable({
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
startTime: MediaTime;
|
||||
}) => {
|
||||
const duration =
|
||||
asset.duration != null
|
||||
? Math.round(asset.duration * TICKS_PER_SECOND)
|
||||
? mediaTimeFromSeconds({ seconds: asset.duration })
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: asset.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
||||
import {
|
||||
Select,
|
||||
@@ -34,6 +34,10 @@ import type { TCanvasSize } from "@/project/types";
|
||||
|
||||
type SettingsView = "project-info" | "background";
|
||||
|
||||
function isSettingsView(value: string): value is SettingsView {
|
||||
return value === "project-info" || value === "background";
|
||||
}
|
||||
|
||||
const PRESET_LABELS: Record<string, string> = {
|
||||
"1:1": "1:1",
|
||||
"16:9": "16:9",
|
||||
@@ -73,23 +77,20 @@ function useCanvasDimensionDraft({
|
||||
value: number;
|
||||
onCommit: (value: number) => void;
|
||||
}) {
|
||||
const pendingValueRef = useRef(value);
|
||||
const syncedValueRef = useRef(value);
|
||||
|
||||
if (syncedValueRef.current !== value) {
|
||||
syncedValueRef.current = value;
|
||||
pendingValueRef.current = value;
|
||||
}
|
||||
const [pendingValue, setPendingValue] = useState(value);
|
||||
|
||||
return usePropertyDraft({
|
||||
displayValue: formatCanvasDimension({ value }),
|
||||
parse: (input) => parseCanvasDimension({ input }),
|
||||
onStartEditing: () => {
|
||||
setPendingValue(value);
|
||||
},
|
||||
onPreview: (nextValue) => {
|
||||
pendingValueRef.current = nextValue;
|
||||
setPendingValue(nextValue);
|
||||
},
|
||||
onCommit: () => {
|
||||
if (pendingValueRef.current !== value) {
|
||||
onCommit(pendingValueRef.current);
|
||||
if (pendingValue !== value) {
|
||||
onCommit(pendingValue);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -212,7 +213,14 @@ export function SettingsView() {
|
||||
contentClassName="px-0"
|
||||
scrollClassName="pt-0"
|
||||
actions={
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(value) => {
|
||||
if (isSettingsView(value)) {
|
||||
setView(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="project-info">Project info</TabsTrigger>
|
||||
<TabsTrigger value="background">Background</TabsTrigger>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+32
-6
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { ParamDefinition, NumberParamDefinition } from "@/params";
|
||||
import type {
|
||||
ParamDefinition,
|
||||
NumberParamDefinition,
|
||||
ParamValue,
|
||||
} from "@/params";
|
||||
import {
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { KeyframeToggle } from "./keyframe-toggle";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function PropertyParamField({
|
||||
param,
|
||||
@@ -28,8 +33,8 @@ export function PropertyParamField({
|
||||
keyframe,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: number | string | boolean;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
value: ParamValue;
|
||||
onPreview: (value: ParamValue) => void;
|
||||
onCommit: () => void;
|
||||
keyframe?: {
|
||||
isActive: boolean;
|
||||
@@ -41,7 +46,7 @@ export function PropertyParamField({
|
||||
<SectionField
|
||||
label={param.label}
|
||||
beforeLabel={
|
||||
keyframe ? (
|
||||
keyframe && param.keyframable !== false ? (
|
||||
<KeyframeToggle
|
||||
isActive={keyframe.isActive}
|
||||
isDisabled={keyframe.isDisabled}
|
||||
@@ -68,8 +73,8 @@ function ParamInput({
|
||||
onCommit,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: number | string | boolean;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
value: ParamValue;
|
||||
onPreview: (value: ParamValue) => void;
|
||||
onCommit: () => void;
|
||||
}) {
|
||||
if (param.type === "number") {
|
||||
@@ -131,6 +136,27 @@ function ParamInput({
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === "text") {
|
||||
return (
|
||||
<Textarea
|
||||
value={String(value)}
|
||||
onChange={(event) => onPreview(event.currentTarget.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (param.type === "font") {
|
||||
return (
|
||||
<input
|
||||
className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none"
|
||||
value={String(value)}
|
||||
onChange={(event) => onPreview(event.currentTarget.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { getElementLocalTime } from "@/animation";
|
||||
import { addMediaTime, mediaTime, type MediaTime } from "@/wasm";
|
||||
|
||||
export function useElementPlayhead({
|
||||
startTime,
|
||||
duration,
|
||||
}: {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
startTime: MediaTime;
|
||||
duration: MediaTime;
|
||||
}) {
|
||||
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: startTime,
|
||||
elementDuration: duration,
|
||||
const localTime = mediaTime({
|
||||
ticks: getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: startTime,
|
||||
elementDuration: duration,
|
||||
}),
|
||||
});
|
||||
const isPlayheadWithinElementRange =
|
||||
playheadTime >= startTime &&
|
||||
playheadTime <= startTime + duration;
|
||||
playheadTime <= addMediaTime({ a: startTime, b: duration });
|
||||
|
||||
return { localTime, isPlayheadWithinElementRange };
|
||||
}
|
||||
|
||||
-95
@@ -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,
|
||||
};
|
||||
}
|
||||
-174
@@ -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,
|
||||
};
|
||||
}
|
||||
+19
-16
@@ -3,18 +3,21 @@
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
buildGraphicParamPath,
|
||||
coerceAnimationValueForParam,
|
||||
getKeyframeAtTime,
|
||||
getParamDefaultInterpolation,
|
||||
getParamValueKind,
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import {
|
||||
coerceParamValue,
|
||||
getParamChannelLayout,
|
||||
type ParamDefinition,
|
||||
} from "@/params";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export interface KeyframedParamPropertyResult {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
@@ -30,6 +33,7 @@ export function useKeyframedParamProperty({
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
@@ -39,7 +43,8 @@ export function useKeyframedParamProperty({
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
propertyPath?: AnimationPath;
|
||||
localTime: MediaTime;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number | string | boolean;
|
||||
buildBaseUpdates: ({
|
||||
@@ -49,15 +54,16 @@ export function useKeyframedParamProperty({
|
||||
}) => Partial<TimelineElement>;
|
||||
}): KeyframedParamPropertyResult {
|
||||
const editor = useEditor();
|
||||
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
||||
const resolvedPropertyPath =
|
||||
propertyPath ?? buildGraphicParamPath({ paramKey: param.key });
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
propertyPath: resolvedPropertyPath,
|
||||
});
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
propertyPath: resolvedPropertyPath,
|
||||
time: localTime,
|
||||
})
|
||||
: null;
|
||||
@@ -76,15 +82,12 @@ export function useKeyframedParamProperty({
|
||||
updates: {
|
||||
animations: upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
propertyPath: resolvedPropertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({
|
||||
param,
|
||||
}),
|
||||
channelLayout: getParamChannelLayout({ param }),
|
||||
coerceValue: ({ value: nextValue }) =>
|
||||
coerceAnimationValueForParam({
|
||||
coerceParamValue({
|
||||
param,
|
||||
value: nextValue,
|
||||
}),
|
||||
@@ -118,7 +121,7 @@ export function useKeyframedParamProperty({
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
propertyPath: resolvedPropertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
@@ -131,7 +134,7 @@ export function useKeyframedParamProperty({
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
propertyPath: resolvedPropertyPath,
|
||||
time: localTime,
|
||||
value: resolvedValue,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useReducer, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { evaluateMathExpression } from "@/utils/math";
|
||||
|
||||
function looksLikeExpression({ input }: { input: string }): boolean {
|
||||
@@ -14,59 +14,56 @@ export function usePropertyDraft<T>({
|
||||
parse,
|
||||
onPreview,
|
||||
onCommit,
|
||||
onStartEditing,
|
||||
supportsExpressions = true,
|
||||
}: {
|
||||
displayValue: string;
|
||||
parse: (input: string) => T | null;
|
||||
onPreview: (value: T) => void;
|
||||
onCommit: () => void;
|
||||
onStartEditing?: () => void;
|
||||
supportsExpressions?: boolean;
|
||||
}) {
|
||||
const [, forceRender] = useReducer(
|
||||
(renderVersion: number) => renderVersion + 1,
|
||||
0,
|
||||
);
|
||||
const isEditing = useRef(false);
|
||||
const draft = useRef("");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
return {
|
||||
displayValue: isEditing.current ? draft.current : sourceDisplay,
|
||||
displayValue: isEditing ? draft : sourceDisplay,
|
||||
scrubTo: (value: number) => {
|
||||
const parsed = parse(String(value));
|
||||
if (parsed !== null) onPreview(parsed);
|
||||
},
|
||||
commitScrub: onCommit,
|
||||
onFocus: () => {
|
||||
isEditing.current = true;
|
||||
draft.current = sourceDisplay;
|
||||
forceRender();
|
||||
setIsEditing(true);
|
||||
setDraft(sourceDisplay);
|
||||
onStartEditing?.();
|
||||
},
|
||||
onChange: (
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
draft.current = event.target.value;
|
||||
forceRender();
|
||||
const nextDraft = event.target.value;
|
||||
setDraft(nextDraft);
|
||||
|
||||
const parsed = parse(event.target.value);
|
||||
const parsed = parse(nextDraft);
|
||||
if (parsed !== null) {
|
||||
onPreview(parsed);
|
||||
}
|
||||
},
|
||||
onBlur: () => {
|
||||
if (
|
||||
supportsExpressions &&
|
||||
looksLikeExpression({ input: draft.current })
|
||||
) {
|
||||
const evaluated = evaluateMathExpression({ input: draft.current });
|
||||
onBlur: (
|
||||
event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const nextDraft = event.target.value;
|
||||
if (supportsExpressions && looksLikeExpression({ input: nextDraft })) {
|
||||
const evaluated = evaluateMathExpression({ input: nextDraft });
|
||||
if (evaluated !== null) {
|
||||
const parsed = parse(String(evaluated));
|
||||
if (parsed !== null) onPreview(parsed);
|
||||
}
|
||||
}
|
||||
onCommit();
|
||||
isEditing.current = false;
|
||||
draft.current = "";
|
||||
forceRender();
|
||||
setIsEditing(false);
|
||||
setDraft("");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +71,12 @@ export function PropertiesPanel() {
|
||||
<Button
|
||||
variant={tab.id === activeTab.id ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setActiveTab(element.type, tab.id)}
|
||||
onClick={() =>
|
||||
setActiveTab({
|
||||
elementType: element.type,
|
||||
tabId: tab.id,
|
||||
})
|
||||
}
|
||||
aria-label={tab.label}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
|
||||
@@ -22,16 +22,43 @@ import {
|
||||
MagicWand05Icon,
|
||||
DashboardSpeed02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { TransformTab } from "@/rendering/components/transform-tab";
|
||||
import { BlendingTab } from "@/rendering/components/blending-tab";
|
||||
import { AudioTab } from "@/timeline/components/audio-tab";
|
||||
import { TextTab } from "@/text/components/text-tab";
|
||||
import { ElementParamsTab } from "./components/element-params-tab";
|
||||
import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab";
|
||||
import { MasksTab } from "@/masks/components/masks-tab";
|
||||
import { SpeedTab } from "@/speed/components/speed-tab";
|
||||
import { GraphicTab } from "@/graphics/components/graphic-tab";
|
||||
import { OcShapesIcon } from "@/components/icons";
|
||||
|
||||
const TRANSFORM_PARAM_KEYS = [
|
||||
"transform.positionX",
|
||||
"transform.positionY",
|
||||
"transform.scaleX",
|
||||
"transform.scaleY",
|
||||
"transform.rotate",
|
||||
] as const;
|
||||
|
||||
const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const;
|
||||
const AUDIO_PARAM_KEYS = ["volume", "muted"] as const;
|
||||
const TEXT_PARAM_KEYS = [
|
||||
"content",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"color",
|
||||
"textAlign",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"textDecoration",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"background.enabled",
|
||||
"background.color",
|
||||
"background.cornerRadius",
|
||||
"background.paddingX",
|
||||
"background.paddingY",
|
||||
"background.offsetX",
|
||||
"background.offsetY",
|
||||
] as const;
|
||||
|
||||
export type TabContentProps = {
|
||||
trackId: string;
|
||||
};
|
||||
@@ -58,7 +85,12 @@ function buildTransformTab({
|
||||
label: "Transform",
|
||||
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<TransformTab element={element} trackId={trackId} />
|
||||
<ElementParamsTab
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
paramKeys={TRANSFORM_PARAM_KEYS}
|
||||
sectionKey="transform"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -73,7 +105,12 @@ function buildBlendingTab({
|
||||
label: "Blending",
|
||||
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<BlendingTab element={element} trackId={trackId} />
|
||||
<ElementParamsTab
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
paramKeys={BLENDING_PARAM_KEYS}
|
||||
sectionKey="blending"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -87,7 +124,14 @@ function buildAudioTab({
|
||||
id: "audio",
|
||||
label: "Audio",
|
||||
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
|
||||
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
|
||||
content: ({ trackId }) => (
|
||||
<ElementParamsTab
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
paramKeys={AUDIO_PARAM_KEYS}
|
||||
sectionKey="audio"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
|
||||
id: "text",
|
||||
label: "Text",
|
||||
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
|
||||
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
|
||||
content: ({ trackId }) => (
|
||||
<ElementParamsTab
|
||||
element={element}
|
||||
trackId={trackId}
|
||||
paramKeys={TEXT_PARAM_KEYS}
|
||||
sectionKey="text"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,18 @@ import { create } from "zustand";
|
||||
|
||||
interface PropertiesState {
|
||||
activeTabPerType: Record<string, string>;
|
||||
setActiveTab: (elementType: string, tabId: string) => void;
|
||||
setActiveTab: (args: { elementType: string; tabId: string }) => void;
|
||||
isTransformScaleLocked: boolean;
|
||||
setTransformScaleLocked: (locked: boolean) => void;
|
||||
setTransformScaleLocked: (args: { locked: boolean }) => void;
|
||||
}
|
||||
|
||||
export const usePropertiesStore = create<PropertiesState>()((set) => ({
|
||||
activeTabPerType: {},
|
||||
setActiveTab: (elementType, tabId) =>
|
||||
setActiveTab: ({ elementType, tabId }) =>
|
||||
set((state) => ({
|
||||
activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
|
||||
})),
|
||||
isTransformScaleLocked: false,
|
||||
setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
|
||||
setTransformScaleLocked: ({ locked }) =>
|
||||
set({ isTransformScaleLocked: locked }),
|
||||
}));
|
||||
|
||||
@@ -39,7 +39,7 @@ export class EditorCore {
|
||||
this.project = new ProjectManager(this);
|
||||
this.media = new MediaManager(this);
|
||||
this.renderer = new RendererManager(this);
|
||||
this.save = new SaveManager(this);
|
||||
this.save = new SaveManager({ editor: this });
|
||||
this.audio = new AudioManager(this);
|
||||
this.selection = new SelectionManager(this);
|
||||
this.clipboard = new ClipboardManager(this);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type CopyContext,
|
||||
type PasteContext,
|
||||
} from "@/clipboard";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class ClipboardManager {
|
||||
private entry: ClipboardEntry | null = null;
|
||||
@@ -34,7 +35,7 @@ export class ClipboardManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
paste({ time }: { time?: number } = {}): boolean {
|
||||
paste({ time }: { time?: MediaTime } = {}): boolean {
|
||||
if (!this.entry) {
|
||||
return false;
|
||||
}
|
||||
@@ -64,7 +65,7 @@ export class ClipboardManager {
|
||||
};
|
||||
}
|
||||
|
||||
private getPasteContext({ time }: { time?: number }): PasteContext {
|
||||
private getPasteContext({ time }: { time?: MediaTime }): PasteContext {
|
||||
return {
|
||||
editor: this.editor,
|
||||
selectedElements: this.editor.selection.getSelectedElements(),
|
||||
|
||||
@@ -68,9 +68,17 @@ export class MediaManager {
|
||||
|
||||
const command =
|
||||
uniqueIds.length === 1
|
||||
? new RemoveMediaAssetCommand(projectId, uniqueIds[0])
|
||||
? new RemoveMediaAssetCommand({
|
||||
projectId,
|
||||
assetId: uniqueIds[0],
|
||||
})
|
||||
: new BatchCommand(
|
||||
uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)),
|
||||
uniqueIds.map((id) =>
|
||||
new RemoveMediaAssetCommand({
|
||||
projectId,
|
||||
assetId: id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.editor.command.execute({ command });
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import {
|
||||
addMediaTime,
|
||||
clampMediaTime,
|
||||
type MediaTime,
|
||||
mediaTimeFromSeconds,
|
||||
roundFrameTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
||||
export class PlaybackManager {
|
||||
private isPlaying = false;
|
||||
private currentTime = 0;
|
||||
private currentTime: MediaTime = ZERO_MEDIA_TIME;
|
||||
private volume = 1;
|
||||
private muted = false;
|
||||
private previousVolume = 1;
|
||||
private isScrubbing = false;
|
||||
private listeners = new Set<() => void>();
|
||||
private updateListeners = new Set<(time: number) => void>();
|
||||
private seekListeners = new Set<(time: number) => void>();
|
||||
private updateListeners = new Set<(time: MediaTime) => void>();
|
||||
private seekListeners = new Set<(time: MediaTime) => void>();
|
||||
private playbackTimer: number | null = null;
|
||||
private playbackStartWallTime = 0;
|
||||
private playbackStartTime = 0;
|
||||
private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
|
||||
private timelineScopeBound = false;
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
@@ -40,7 +46,7 @@ export class PlaybackManager {
|
||||
}
|
||||
|
||||
if (this.currentTime >= maxTime) {
|
||||
this.seek({ time: 0 });
|
||||
this.seek({ time: ZERO_MEDIA_TIME });
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
@@ -62,7 +68,7 @@ export class PlaybackManager {
|
||||
}
|
||||
}
|
||||
|
||||
seek({ time }: { time: number }): void {
|
||||
seek({ time }: { time: MediaTime }): void {
|
||||
this.currentTime = this.clampTimeToTimeline(time);
|
||||
if (this.isPlaying) {
|
||||
this.playbackStartWallTime = performance.now();
|
||||
@@ -109,7 +115,7 @@ export class PlaybackManager {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
getCurrentTime(): number {
|
||||
getCurrentTime(): MediaTime {
|
||||
return this.currentTime;
|
||||
}
|
||||
|
||||
@@ -135,12 +141,12 @@ export class PlaybackManager {
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
onUpdate(listener: (time: number) => void): () => void {
|
||||
onUpdate(listener: (time: MediaTime) => void): () => void {
|
||||
this.updateListeners.add(listener);
|
||||
return () => this.updateListeners.delete(listener);
|
||||
}
|
||||
|
||||
onSeek(listener: (time: number) => void): () => void {
|
||||
onSeek(listener: (time: MediaTime) => void): () => void {
|
||||
this.seekListeners.add(listener);
|
||||
return () => this.seekListeners.delete(listener);
|
||||
}
|
||||
@@ -165,6 +171,7 @@ export class PlaybackManager {
|
||||
|
||||
if (timeChanged) {
|
||||
this.notifySeek(this.currentTime);
|
||||
this.dispatchSeekEvent(this.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +181,18 @@ export class PlaybackManager {
|
||||
});
|
||||
}
|
||||
|
||||
private notifyUpdate(time: MediaTime): void {
|
||||
this.updateListeners.forEach((fn) => {
|
||||
fn(time);
|
||||
});
|
||||
}
|
||||
|
||||
private notifySeek(time: MediaTime): void {
|
||||
this.seekListeners.forEach((fn) => {
|
||||
fn(time);
|
||||
});
|
||||
}
|
||||
|
||||
private startTimer(): void {
|
||||
if (this.playbackTimer) {
|
||||
cancelAnimationFrame(this.playbackTimer);
|
||||
@@ -197,40 +216,42 @@ export class PlaybackManager {
|
||||
const fps = this.editor.project.getActive()?.settings.fps;
|
||||
const elapsedSeconds =
|
||||
(performance.now() - this.playbackStartWallTime) / 1000;
|
||||
const rawTime =
|
||||
this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
|
||||
const newTime = fps
|
||||
? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
|
||||
: rawTime;
|
||||
const rawTime = addMediaTime({
|
||||
a: this.playbackStartTime,
|
||||
b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
|
||||
});
|
||||
const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime;
|
||||
const maxTime = this.editor.timeline.getTotalDuration();
|
||||
|
||||
if (newTime >= maxTime) {
|
||||
this.pause();
|
||||
this.currentTime = maxTime;
|
||||
this.notify();
|
||||
this.notifySeek(maxTime);
|
||||
return;
|
||||
this.notifySeek(maxTime);
|
||||
this.dispatchSeekEvent(maxTime);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentTime = newTime;
|
||||
this.notifyUpdate(newTime);
|
||||
this.dispatchUpdateEvent(newTime);
|
||||
this.playbackTimer = requestAnimationFrame(this.updateTime);
|
||||
};
|
||||
|
||||
private clampTimeToTimeline(time: number): number {
|
||||
private clampTimeToTimeline(time: MediaTime): MediaTime {
|
||||
const maxTime = this.editor.timeline.getTotalDuration();
|
||||
return Math.max(0, Math.min(maxTime, time));
|
||||
return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime });
|
||||
}
|
||||
|
||||
private notifySeek(time: number): void {
|
||||
for (const fn of this.seekListeners) {
|
||||
fn(time);
|
||||
private dispatchSeekEvent(time: MediaTime): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private notifyUpdate(time: number): void {
|
||||
for (const fn of this.updateListeners) {
|
||||
fn(time);
|
||||
private dispatchUpdateEvent(time: MediaTime): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,18 @@ export class SaveManager {
|
||||
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private unsubscribeHandlers: Array<() => void> = [];
|
||||
|
||||
constructor(
|
||||
private editor: EditorCore,
|
||||
{ debounceMs = 800 }: SaveManagerOptions = {},
|
||||
) {
|
||||
constructor({
|
||||
editor,
|
||||
debounceMs = 800,
|
||||
}: {
|
||||
editor: EditorCore;
|
||||
} & SaveManagerOptions) {
|
||||
this.editor = editor;
|
||||
this.debounceMs = debounceMs;
|
||||
}
|
||||
|
||||
private editor: EditorCore;
|
||||
|
||||
start(): void {
|
||||
if (this.unsubscribeHandlers.length > 0) return;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { SceneTracks, TScene } from "@/timeline";
|
||||
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import {
|
||||
getMainScene,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ToggleBookmarkCommand,
|
||||
UpdateBookmarkCommand,
|
||||
} from "@/commands/scene";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export class ScenesManager {
|
||||
private active: TScene | null = null;
|
||||
@@ -40,7 +41,7 @@ export class ScenesManager {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const command = new CreateSceneCommand(name, isMain);
|
||||
const command = new CreateSceneCommand({ name, isMain });
|
||||
this.editor.command.execute({ command });
|
||||
return command.getSceneId();
|
||||
}
|
||||
@@ -76,7 +77,10 @@ export class ScenesManager {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const command = new RenameSceneCommand(sceneId, name);
|
||||
const command = new RenameSceneCommand({
|
||||
sceneId,
|
||||
newName: name,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
@@ -106,12 +110,12 @@ export class ScenesManager {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async toggleBookmark({ time }: { time: number }): Promise<void> {
|
||||
async toggleBookmark({ time }: { time: MediaTime }): Promise<void> {
|
||||
const command = new ToggleBookmarkCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
isBookmarked({ time }: { time: number }): boolean {
|
||||
isBookmarked({ time }: { time: MediaTime }): boolean {
|
||||
const activeScene = this.getActiveScene();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
@@ -125,7 +129,7 @@ export class ScenesManager {
|
||||
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
|
||||
}
|
||||
|
||||
async removeBookmark({ time }: { time: number }): Promise<void> {
|
||||
async removeBookmark({ time }: { time: MediaTime }): Promise<void> {
|
||||
const command = new RemoveBookmarkCommand(time);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
@@ -134,10 +138,10 @@ export class ScenesManager {
|
||||
time,
|
||||
updates,
|
||||
}: {
|
||||
time: number;
|
||||
updates: Partial<{ note: string; color: string; duration: number }>;
|
||||
time: MediaTime;
|
||||
updates: Partial<Omit<Bookmark, "time">>;
|
||||
}): Promise<void> {
|
||||
const command = new UpdateBookmarkCommand(time, updates);
|
||||
const command = new UpdateBookmarkCommand({ time, updates });
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
@@ -145,14 +149,14 @@ export class ScenesManager {
|
||||
fromTime,
|
||||
toTime,
|
||||
}: {
|
||||
fromTime: number;
|
||||
toTime: number;
|
||||
fromTime: MediaTime;
|
||||
toTime: MediaTime;
|
||||
}): Promise<void> {
|
||||
const command = new MoveBookmarkCommand(fromTime, toTime);
|
||||
const command = new MoveBookmarkCommand({ fromTime, toTime });
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
getBookmarkAtTime({ time }: { time: number }) {
|
||||
getBookmarkAtTime({ time }: { time: MediaTime }) {
|
||||
const activeScene = this.active;
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user