diff options
285 files changed, 10664 insertions, 8343 deletions
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f251603f..5edc56fb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -168,7 +168,7 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README 2. Make your changes 3. Run the relevant checks for the area you touched: - - Web changes: from `apps/web`, run `bun run lint` and `bunx biome format --write .` + - Web changes: from `apps/web`, run `bun run lint` and `bun run format` - Desktop changes: run `./apps/desktop/script/setup` if your environment isn't set up yet 4. Commit your changes with a descriptive message @@ -176,8 +176,8 @@ Working on `apps/desktop`? See [`apps/desktop/README.md`](../apps/desktop/README ## Code Style -- We use Biome for code formatting and linting -- Run `bunx biome format --write .` from the `apps/web` directory to format code +- We use ESLint for linting and Prettier for formatting +- Run `bun run format` from the `apps/web` directory to format code - Run `bun run lint` from the `apps/web` directory to check for linting issues - Follow the existing code patterns diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c30aecc2..77d2c4f0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,344 +1,342 @@ ----
-applyTo: "**/*.{ts,tsx,js,jsx}"
----
-
-# Project Context
-
-Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
-
-## Key Principles
-
-- Zero configuration required
-- Subsecond performance
-- Maximum type safety
-- AI-friendly code generation
-
-## Before Writing Code
-
-1. Analyze existing patterns in the codebase
-2. Consider edge cases and error scenarios
-3. Follow the rules below strictly
-4. Validate accessibility requirements
-
-## Rules
-
-### Accessibility (a11y)
-
-- Don't use `accessKey` attribute on any HTML element.
-- Don't set `aria-hidden="true"` on focusable elements.
-- Don't add ARIA roles, states, and properties to elements that don't support them.
-- Don't use distracting elements like `<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); +} +``` @@ -23,4 +23,6 @@ bun.lockb target/ -.cargo-tools/
\ No newline at end of file +.cargo-tools/ + +rust-migration-notes.md
\ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..13be308b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +.next +node_modules +dist +build +target +rust/wasm/pkg diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..c9590876 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "useTabs": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 187722fb..2fc093b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,14 +7,7 @@ "editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
- "source.fixAll.biome": "explicit",
- "source.organizeImports.biome": "explicit"
+ "source.fixAll.eslint": "explicit"
},
- "emmet.showExpandedAbbreviation": "never",
- "[typescriptreact]": {
- "editor.defaultFormatter": "biomejs.biome"
- },
- "[typescript]": {
- "editor.defaultFormatter": "biomejs.biome"
- }
+ "emmet.showExpandedAbbreviation": "never"
}
@@ -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. - @@ -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
\ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 0034ebad..90cd9fb5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,9 +9,9 @@ "start": "next start", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", - "lint": "biome check src/", - "lint:fix": "biome check src/ --write", - "format": "biome format src/ --write", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "format": "prettier src --write", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push:local": "cross-env NODE_ENV=development drizzle-kit push", @@ -52,7 +52,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.2.9", + "opencut-wasm": "^0.2.10", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", diff --git a/apps/web/src/actions/components/shortcuts-dialog.tsx b/apps/web/src/actions/components/shortcuts-dialog.tsx index cd9c0242..7f7d8a87 100644 --- a/apps/web/src/actions/components/shortcuts-dialog.tsx +++ b/apps/web/src/actions/components/shortcuts-dialog.tsx @@ -51,10 +51,10 @@ export function ShortcutsDialog({ const keyString = getKeybindingString(e);
if (keyString) {
- const conflict = validateKeybinding(
- keyString,
- recordingShortcut.action,
- );
+ const conflict = validateKeybinding({
+ key: keyString,
+ action: recordingShortcut.action,
+ });
if (conflict) {
toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
@@ -68,7 +68,10 @@ export function ShortcutsDialog({ removeKeybinding(key);
}
- updateKeybinding(keyString, recordingShortcut.action);
+ updateKeybinding({
+ key: keyString,
+ action: recordingShortcut.action,
+ });
setIsRecording(false);
setRecordingShortcut(null);
diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts index 6f0ba700..986510ec 100644 --- a/apps/web/src/actions/definitions.ts +++ b/apps/web/src/actions/definitions.ts @@ -1,7 +1,4 @@ -import type { - KeybindingConfig, - ShortcutKey, -} from "@/actions/keybinding"; +import type { ShortcutKey } from "@/actions/keybinding"; import type { TActionWithOptionalArgs } from "./types"; export type TActionCategory = @@ -155,33 +152,36 @@ export const ACTIONS = { export type TAction = keyof typeof ACTIONS; -const ACTION_DEFAULT_SHORTCUTS = { - "toggle-play": ["space", "k"], - "seek-forward": ["l"], - "seek-backward": ["j"], - "frame-step-forward": ["right"], - "frame-step-backward": ["left"], - "jump-forward": ["shift+right"], - "jump-backward": ["shift+left"], - "goto-start": ["home", "enter"], - "goto-end": ["end"], - split: ["s"], - "split-left": ["q"], - "split-right": ["w"], - "delete-selected": ["backspace", "delete"], - "copy-selected": ["ctrl+c"], - "paste-copied": ["ctrl+v"], - "toggle-snapping": ["n"], - "select-all": ["ctrl+a"], - "cancel-interaction": ["escape"], - "duplicate-selected": ["ctrl+d"], - undo: ["ctrl+z"], - redo: ["ctrl+shift+z", "ctrl+y"], -} as const satisfies Partial<Record<TActionWithOptionalArgs, readonly ShortcutKey[]>>; +const ACTION_DEFAULT_SHORTCUTS = [ + ["toggle-play", ["space", "k"]], + ["seek-forward", ["l"]], + ["seek-backward", ["j"]], + ["frame-step-forward", ["right"]], + ["frame-step-backward", ["left"]], + ["jump-forward", ["shift+right"]], + ["jump-backward", ["shift+left"]], + ["goto-start", ["home", "enter"]], + ["goto-end", ["end"]], + ["split", ["s"]], + ["split-left", ["q"]], + ["split-right", ["w"]], + ["delete-selected", ["backspace", "delete"]], + ["copy-selected", ["ctrl+c"]], + ["paste-copied", ["ctrl+v"]], + ["toggle-snapping", ["n"]], + ["select-all", ["ctrl+a"]], + ["cancel-interaction", ["escape"]], + ["duplicate-selected", ["ctrl+d"]], + ["undo", ["ctrl+z"]], + ["redo", ["ctrl+shift+z", "ctrl+y"]], +] as const satisfies ReadonlyArray< + readonly [TActionWithOptionalArgs, readonly ShortcutKey[]] +>; -const ACTION_DEFAULT_SHORTCUTS_BY_ACTION: Partial< - Record<TAction, readonly ShortcutKey[]> -> = ACTION_DEFAULT_SHORTCUTS; +const ACTION_DEFAULT_SHORTCUTS_BY_ACTION = new Map< + TAction, + readonly ShortcutKey[] +>(ACTION_DEFAULT_SHORTCUTS); export function getActionDefinition({ action, @@ -190,18 +190,19 @@ export function getActionDefinition({ }): TActionDefinition { return { ...ACTIONS[action], - defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION[action], + defaultShortcuts: ACTION_DEFAULT_SHORTCUTS_BY_ACTION.get(action), }; } -export function getDefaultShortcuts(): KeybindingConfig { - const shortcuts: KeybindingConfig = {}; +export function getDefaultShortcuts(): Map< + ShortcutKey, + TActionWithOptionalArgs +> { + const shortcuts = new Map<ShortcutKey, TActionWithOptionalArgs>(); - for (const [action, defaultShortcuts] of Object.entries( - ACTION_DEFAULT_SHORTCUTS, - ) as Array<[TActionWithOptionalArgs, readonly ShortcutKey[]]>) { + for (const [action, defaultShortcuts] of ACTION_DEFAULT_SHORTCUTS) { for (const shortcut of defaultShortcuts) { - shortcuts[shortcut] = action; + shortcuts.set(shortcut, action); } } diff --git a/apps/web/src/actions/keybinding.ts b/apps/web/src/actions/keybinding.ts index 65ef4538..aeec2fd6 100644 --- a/apps/web/src/actions/keybinding.ts +++ b/apps/web/src/actions/keybinding.ts @@ -1,79 +1,43 @@ -import type { TActionWithOptionalArgs } from "./types";
-
-/**
- * Alt is also regarded as macOS OPTION (⌥) key
- * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
- */
-export type ModifierKeys =
- | "ctrl"
- | "alt"
- | "shift"
- | "ctrl+shift"
- | "alt+shift"
- | "ctrl+alt"
- | "ctrl+alt+shift";
-
-export type Key =
- | "a"
- | "b"
- | "c"
- | "d"
- | "e"
- | "f"
- | "g"
- | "h"
- | "i"
- | "j"
- | "k"
- | "l"
- | "m"
- | "n"
- | "o"
- | "p"
- | "q"
- | "r"
- | "s"
- | "t"
- | "u"
- | "v"
- | "w"
- | "x"
- | "y"
- | "z"
- | "0"
- | "1"
- | "2"
- | "3"
- | "4"
- | "5"
- | "6"
- | "7"
- | "8"
- | "9"
- | "up"
- | "down"
- | "left"
- | "right"
- | "/"
- | "?"
- | "."
- | "enter"
- | "tab"
- | "space"
- | "escape"
- | "esc"
- | "backspace"
- | "delete"
- | "home"
- | "end";
-/* eslint-enable */
-
-export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;
-// Singular keybindings (these will be disabled when an input-ish area has been focused)
-export type SingleCharacterShortcutKey = `${Key}`;
-
-export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey;
-
-export type KeybindingConfig = {
- [key in ShortcutKey]?: TActionWithOptionalArgs;
-};
+import type { TActionWithOptionalArgs } from "./types"; + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +export type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift"; + +const KEYS = [ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", + "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", + "u", "v", "w", "x", "y", "z", + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + "up", "down", "left", "right", + "/", "?", ".", + "enter", "tab", "space", "escape", "esc", + "backspace", "delete", "home", "end", +] as const; + +export type Key = (typeof KEYS)[number]; + +const KEY_SET: ReadonlySet<string> = new Set(KEYS); + +export function isKey(value: string): value is Key { + return KEY_SET.has(value); +} + +export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; +// Singular keybindings (these will be disabled when an input-ish area has been focused) +export type SingleCharacterShortcutKey = `${Key}`; + +export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; + +export type KeybindingConfig = { + [key in ShortcutKey]?: TActionWithOptionalArgs; +}; diff --git a/apps/web/src/actions/keybindings-store.ts b/apps/web/src/actions/keybindings-store.ts index c0a1a5e2..8f344487 100644 --- a/apps/web/src/actions/keybindings-store.ts +++ b/apps/web/src/actions/keybindings-store.ts @@ -6,11 +6,15 @@ import type { TActionWithOptionalArgs } from "@/actions"; import { getDefaultShortcuts } from "@/actions"; import { isTypableDOMElement } from "@/utils/browser"; import { isAppleDevice } from "@/utils/platform"; -import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding"; +import type { + Key, + KeybindingConfig, + ModifierKeys, + ShortcutKey, +} from "@/actions/keybinding"; +import { isKey } from "@/actions/keybinding"; import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; -const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); - export interface KeybindingConflict { key: ShortcutKey; existingAction: TActionWithOptionalArgs; @@ -18,38 +22,57 @@ export interface KeybindingConflict { } interface KeybindingsState { - keybindings: KeybindingConfig; + keybindings: Map<ShortcutKey, TActionWithOptionalArgs>; isCustomized: boolean; overlayDepth: number; openOverlayIds: string[]; isLoadingProject: boolean; isRecording: boolean; - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; + updateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => void; removeKeybinding: (key: ShortcutKey) => void; resetToDefaults: () => void; importKeybindings: (config: KeybindingConfig) => void; - exportKeybindings: () => KeybindingConfig; + exportKeybindings: () => Record<string, TActionWithOptionalArgs>; openOverlay: (overlayId: string) => void; closeOverlay: (overlayId: string) => void; setLoadingProject: (loading: boolean) => void; setIsRecording: (isRecording: boolean) => void; - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => KeybindingConflict | null; + validateKeybinding: (params: { + key: ShortcutKey; + action: TActionWithOptionalArgs; + }) => KeybindingConflict | null; getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; } +type PersistedState = { + keybindings: Record<string, TActionWithOptionalArgs>; + isCustomized: boolean; +}; + function isDOMElement(element: EventTarget | null): element is HTMLElement { return element instanceof HTMLElement; } +function isPersistedState(value: unknown): value is PersistedState { + if (!value || typeof value !== "object") return false; + if (!("keybindings" in value) || !("isCustomized" in value)) return false; + const { keybindings, isCustomized } = value; + return ( + typeof keybindings === "object" && + keybindings !== null && + typeof isCustomized === "boolean" + ); +} + export const useKeybindingsStore = create<KeybindingsState>()( persist( (set, get) => ({ - keybindings: { ...defaultKeybindings }, + keybindings: getDefaultShortcuts(), isCustomized: false, overlayDepth: 0, openOverlayIds: [], @@ -61,10 +84,9 @@ export const useKeybindingsStore = create<KeybindingsState>()( const openOverlayIds = s.openOverlayIds.includes(overlayId) ? s.openOverlayIds : [...s.openOverlayIds, overlayId]; - const nextOverlayDepth = openOverlayIds.length; return { openOverlayIds, - overlayDepth: nextOverlayDepth, + overlayDepth: openOverlayIds.length, }; }), closeOverlay: (overlayId) => @@ -72,35 +94,32 @@ export const useKeybindingsStore = create<KeybindingsState>()( const openOverlayIds = s.openOverlayIds.filter( (id) => id !== overlayId, ); - const nextOverlayDepth = openOverlayIds.length; return { openOverlayIds, - overlayDepth: nextOverlayDepth, + overlayDepth: openOverlayIds.length, }; }), setLoadingProject: (loading) => { set({ isLoadingProject: loading }); }, - updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { + updateKeybinding: ({ key, action }) => { set((state) => { - const newKeybindings = { ...state.keybindings }; - newKeybindings[key] = action; - + const next = new Map(state.keybindings); + next.set(key, action); return { - keybindings: newKeybindings, + keybindings: next, isCustomized: true, }; }); }, - removeKeybinding: (key: ShortcutKey) => { + removeKeybinding: (key) => { set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; - + const next = new Map(state.keybindings); + next.delete(key); return { - keybindings: newKeybindings, + keybindings: next, isCustomized: true, }; }); @@ -108,34 +127,35 @@ export const useKeybindingsStore = create<KeybindingsState>()( resetToDefaults: () => { set({ - keybindings: { ...defaultKeybindings }, + keybindings: getDefaultShortcuts(), isCustomized: false, }); }, - importKeybindings: (config: KeybindingConfig) => { - for (const [key] of Object.entries(config)) { + importKeybindings: (config) => { + const next = new Map<ShortcutKey, TActionWithOptionalArgs>(); + for (const [key, action] of Object.entries(config)) { if (typeof key !== "string" || key.length === 0) { throw new Error(`Invalid key format: ${key}`); } + if (action !== undefined) { + // Public type's keys are `ShortcutKey`; trust the caller's typing. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + next.set(key as ShortcutKey, action); + } } set({ - keybindings: { ...config }, + keybindings: next, isCustomized: true, }); }, exportKeybindings: () => { - return get().keybindings; + return Object.fromEntries(get().keybindings); }, - validateKeybinding: ( - key: ShortcutKey, - action: TActionWithOptionalArgs, - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; - + validateKeybinding: ({ key, action }) => { + const existingAction = get().keybindings.get(key); if (existingAction && existingAction !== action) { return { key, @@ -143,33 +163,45 @@ export const useKeybindingsStore = create<KeybindingsState>()( newAction: action, }; } - return null; }, - setIsRecording: (isRecording: boolean) => { + setIsRecording: (isRecording) => { set({ isRecording }); }, - getKeybindingsForAction: (action: TActionWithOptionalArgs) => { - const { keybindings } = get(); - return Object.keys(keybindings).filter( - (key) => keybindings[key as ShortcutKey] === action, - ) as ShortcutKey[]; + getKeybindingsForAction: (action) => { + const result: ShortcutKey[] = []; + for (const [key, mapped] of get().keybindings) { + if (mapped === action) result.push(key); + } + return result; }, - getKeybindingString: (ev: KeyboardEvent) => { - return generateKeybindingString(ev) as ShortcutKey | null; - }, + getKeybindingString: (ev) => generateKeybindingString(ev), }), { name: "opencut-keybindings", version: CURRENT_VERSION, - partialize: (state) => ({ - keybindings: state.keybindings, + partialize: (state): PersistedState => ({ + keybindings: Object.fromEntries(state.keybindings), isCustomized: state.isCustomized, }), migrate: (persisted, version) => runMigrations({ state: persisted, fromVersion: version }), + merge: (persisted, current) => { + if (!isPersistedState(persisted)) return current; + const entries = Object.entries(persisted.keybindings); + // Persistence boundary: keys are normalized by the migration chain. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const typedEntries = entries as Array< + [ShortcutKey, TActionWithOptionalArgs] + >; + return { + ...current, + keybindings: new Map(typedEntries), + isCustomized: persisted.isCustomized, + }; + }, }, ), ); @@ -184,68 +216,59 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { if ( modifierKey === "shift" && isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) + isTypableDOMElement({ element: target }) ) { return null; } - return `${modifierKey}+${key}` as ShortcutKey; + return `${modifierKey}+${key}`; } - if ( - isDOMElement(target) && - isTypableDOMElement({ element: target as HTMLElement }) - ) + if (isDOMElement(target) && isTypableDOMElement({ element: target })) { return null; + } - return `${key}` as ShortcutKey; + return key; } -function getPressedKey(ev: KeyboardEvent): string | null { - const key = (ev.key ?? "").toLowerCase(); +function getPressedKey(ev: KeyboardEvent): Key | null { + const raw = (ev.key ?? "").toLowerCase(); const code = ev.code ?? ""; - if (code === "Space" || key === " " || key === "spacebar" || key === "space") + if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space") return "space"; - if (key.startsWith("arrow")) return key.slice(5); - - if (key === "escape") return "escape"; - if (key === "tab") return "tab"; - if (key === "home") return "home"; - if (key === "end") return "end"; - if (key === "delete") return "delete"; - if (key === "backspace") return "backspace"; + if (raw === "arrowup") return "up"; + if (raw === "arrowdown") return "down"; + if (raw === "arrowleft") return "left"; + if (raw === "arrowright") return "right"; if (code.startsWith("Key")) { const letter = code.slice(3).toLowerCase(); - if (letter.length === 1 && letter >= "a" && letter <= "z") return letter; + if (isKey(letter)) return letter; } - // Use physical key position for AZERTY and other non-QWERTY layouts + // Use physical key position for AZERTY and other non-QWERTY layouts. if (code.startsWith("Digit")) { const digit = code.slice(5); - if (digit.length === 1 && digit >= "0" && digit <= "9") return digit; + if (isKey(digit)) return digit; } - const isDigit = key.length === 1 && key >= "0" && key <= "9"; - if (isDigit) return key; - - if (key === "/" || key === "." || key === "enter") return key; - + if (isKey(raw)) return raw; return null; } -function getActiveModifier(ev: KeyboardEvent): string | null { - const modifierKeys = { - ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, - alt: ev.altKey, - shift: ev.shiftKey, - }; +function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null { + const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey; + const alt = ev.altKey; + const shift = ev.shiftKey; - const activeModifier = Object.keys(modifierKeys) - .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) - .join("+"); - - return activeModifier === "" ? null : activeModifier; + if (ctrl && alt && shift) return "ctrl+alt+shift"; + if (ctrl && alt) return "ctrl+alt"; + if (ctrl && shift) return "ctrl+shift"; + if (alt && shift) return "alt+shift"; + if (ctrl) return "ctrl"; + if (alt) return "alt"; + if (shift) return "shift"; + return null; } diff --git a/apps/web/src/actions/keybindings/__tests__/persistence.test.ts b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts new file mode 100644 index 00000000..bcccc6a4 --- /dev/null +++ b/apps/web/src/actions/keybindings/__tests__/persistence.test.ts @@ -0,0 +1,144 @@ +import { + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { + decodePersistedKeybindingsState, + migratePersistedKeybindingsState, + parseImportedKeybindings, + serializeKeybindingsState, +} from "../persistence"; + +describe("keybinding persistence", () => { + let warnSpy: ReturnType<typeof mock>; + let originalWarn: typeof console.warn; + + beforeEach(() => { + originalWarn = console.warn; + warnSpy = mock(() => {}); + console.warn = warnSpy; + }); + + afterEach(() => { + console.warn = originalWarn; + }); + + test("migrates legacy persisted keybindings before decoding them", () => { + const migrated = migratePersistedKeybindingsState({ + state: { + keybindings: { + s: "split-selected", + "ctrl+v": "paste-selected", + }, + isCustomized: true, + }, + fromVersion: 2, + }); + + const decoded = decodePersistedKeybindingsState({ state: migrated }); + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected migrated keybindings to decode"); + + expect(decoded.isCustomized).toBe(true); + expect(decoded.keybindings.get("s")).toBe("split"); + expect(decoded.keybindings.get("ctrl+v")).toBe("paste-copied"); + expect(decoded.keybindings.get("escape")).toBe("cancel-interaction"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("filters invalid persisted entries at the boundary and warns", () => { + const decoded = decodePersistedKeybindingsState({ + state: { + keybindings: { + space: "toggle-play", + "shift+bogus": "toggle-play", + "ctrl+v": "not-an-action", + }, + isCustomized: false, + }, + }); + + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected persisted keybindings to decode"); + + expect(Array.from(decoded.keybindings.entries())).toEqual([ + ["space", "toggle-play"], + ]); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test("returns null and warns when persisted shape is unrecognizable", () => { + const decoded = decodePersistedKeybindingsState({ state: "garbage" }); + expect(decoded).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + test("round-trips actions that have no default shortcut", () => { + // `stop-playback` is a valid `TActionWithOptionalArgs` but is not in the + // defaults table; the validator must still accept it. + const serialized = serializeKeybindingsState({ + keybindings: new Map([ + ["space", "toggle-play"], + ["x", "stop-playback"], + ["b", "toggle-bookmark"], + ]), + isCustomized: true, + }); + + const decoded = decodePersistedKeybindingsState({ state: serialized }); + expect(decoded).not.toBeNull(); + if (!decoded) throw new Error("Expected round-tripped keybindings"); + + expect(decoded.keybindings.get("space")).toBe("toggle-play"); + expect(decoded.keybindings.get("x")).toBe("stop-playback"); + expect(decoded.keybindings.get("b")).toBe("toggle-bookmark"); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe("parseImportedKeybindings", () => { + test("accepts a valid configuration", () => { + const result = parseImportedKeybindings({ + config: { + space: "toggle-play", + x: "stop-playback", + }, + }); + + expect(result.get("space")).toBe("toggle-play"); + expect(result.get("x")).toBe("stop-playback"); + }); + + test("throws on non-object input", () => { + expect(() => parseImportedKeybindings({ config: null })).toThrow( + /JSON object/, + ); + expect(() => parseImportedKeybindings({ config: [] })).toThrow( + /JSON object/, + ); + }); + + test("throws on non-string action values", () => { + expect(() => + parseImportedKeybindings({ config: { space: 42 } }), + ).toThrow(/expected string/); + }); + + test("throws on invalid shortcut keys", () => { + expect(() => + parseImportedKeybindings({ + config: { "shift+bogus": "toggle-play" }, + }), + ).toThrow(/shift\+bogus/); + }); + + test("throws on invalid actions", () => { + expect(() => + parseImportedKeybindings({ config: { space: "not-an-action" } }), + ).toThrow(/not-an-action/); + }); +}); diff --git a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts index 0171c5a5..81fd86a7 100644 --- a/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts +++ b/apps/web/src/actions/keybindings/migrations/v2-to-v3.ts @@ -1,13 +1,8 @@ -import type { KeybindingConfig, ShortcutKey } from "@/actions/keybinding";
-import type { TActionWithOptionalArgs } from "@/actions";
-
-interface V2State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v2ToV3({ state }: { state: unknown }): unknown {
- const v2 = state as V2State;
+ const v2 = getPersistedKeybindingsState({ state });
+ if (!v2) return state;
const renames: Record<string, string> = {
"split-selected": "split",
@@ -17,8 +12,9 @@ export function v2ToV3({ state }: { state: unknown }): unknown { const migrated = { ...v2.keybindings };
for (const [key, action] of Object.entries(migrated)) {
- if (action && renames[action]) {
- migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ const renamedAction = action ? renames[action] : undefined;
+ if (renamedAction) {
+ migrated[key] = renamedAction;
}
}
diff --git a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts index 8541071b..3724f61c 100644 --- a/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts +++ b/apps/web/src/actions/keybindings/migrations/v3-to-v4.ts @@ -1,14 +1,8 @@ -import type { TActionWithOptionalArgs } from "@/actions";
-import type { ShortcutKey } from "@/actions/keybinding";
-import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V3State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v3ToV4({ state }: { state: unknown }): unknown {
- const v3 = state as V3State;
+ const v3 = getPersistedKeybindingsState({ state });
+ if (!v3) return state;
const renames: Record<string, string> = {
"paste-selected": "paste-copied",
@@ -16,8 +10,9 @@ export function v3ToV4({ state }: { state: unknown }): unknown { const migrated = { ...v3.keybindings };
for (const [key, action] of Object.entries(migrated)) {
- if (action && renames[action]) {
- migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs;
+ const renamedAction = action ? renames[action] : undefined;
+ if (renamedAction) {
+ migrated[key] = renamedAction;
}
}
diff --git a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts index f6937c39..bb60b044 100644 --- a/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts +++ b/apps/web/src/actions/keybindings/migrations/v4-to-v5.ts @@ -1,12 +1,8 @@ -import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V4State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v4ToV5({ state }: { state: unknown }): unknown {
- const v4 = state as V4State;
+ const v4 = getPersistedKeybindingsState({ state });
+ if (!v4) return state;
const keybindings = { ...v4.keybindings };
if (!keybindings.escape) {
diff --git a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts index 09b44307..cb572aac 100644 --- a/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts +++ b/apps/web/src/actions/keybindings/migrations/v5-to-v6.ts @@ -1,12 +1,8 @@ -import type { KeybindingConfig } from "@/actions/keybinding";
-
-interface V5State {
- keybindings: KeybindingConfig;
- isCustomized: boolean;
-}
+import { getPersistedKeybindingsState } from "../persisted-state";
export function v5ToV6({ state }: { state: unknown }): unknown {
- const v5 = state as V5State;
+ const v5 = getPersistedKeybindingsState({ state });
+ if (!v5) return state;
const keybindings = { ...v5.keybindings };
if (keybindings.escape === "deselect-all") {
diff --git a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts index 46a99ac1..9c05db23 100644 --- a/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts +++ b/apps/web/src/actions/keybindings/migrations/v6-to-v7.ts @@ -1,16 +1,12 @@ -import type { KeybindingConfig } from "@/actions/keybinding"; - -interface V6State { - keybindings: KeybindingConfig; - isCustomized: boolean; -} +import { getPersistedKeybindingsState } from "../persisted-state"; export function v6ToV7({ state }: { state: unknown }): unknown { - const v6 = state as V6State; + const v6 = getPersistedKeybindingsState({ state }); + if (!v6) return state; const keybindings = { ...v6.keybindings }; - for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) { - if (keybindings[key] === ("split-element" as never)) { + for (const [key, action] of Object.entries(keybindings)) { + if (action === "split-element") { keybindings[key] = "split"; } } diff --git a/apps/web/src/actions/keybindings/persisted-state.ts b/apps/web/src/actions/keybindings/persisted-state.ts new file mode 100644 index 00000000..57d83f6d --- /dev/null +++ b/apps/web/src/actions/keybindings/persisted-state.ts @@ -0,0 +1,37 @@ +export type PersistedKeybindingConfig = Record<string, string | undefined>; + +export interface PersistedKeybindingsState { + keybindings: PersistedKeybindingConfig; + isCustomized: boolean; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function getPersistedKeybindingsState({ + state, +}: { + state: unknown; +}): PersistedKeybindingsState | null { + if (!isRecord(state)) return null; + + const { keybindings, isCustomized } = state; + if (!isRecord(keybindings) || typeof isCustomized !== "boolean") { + return null; + } + + const normalizedKeybindings: PersistedKeybindingConfig = {}; + for (const [key, action] of Object.entries(keybindings)) { + if (action !== undefined && typeof action !== "string") { + return null; + } + + normalizedKeybindings[key] = action; + } + + return { + keybindings: normalizedKeybindings, + isCustomized, + }; +} diff --git a/apps/web/src/actions/keybindings/persistence.ts b/apps/web/src/actions/keybindings/persistence.ts new file mode 100644 index 00000000..ced44bd0 --- /dev/null +++ b/apps/web/src/actions/keybindings/persistence.ts @@ -0,0 +1,119 @@ +import type { ShortcutKey } from "@/actions/keybinding"; +import { isShortcutKey } from "@/actions/keybinding"; +import type { TActionWithOptionalArgs } from "@/actions"; +import { isActionWithOptionalArgs } from "@/actions"; +import { runMigrations } from "./migrations"; +import { + getPersistedKeybindingsState, + type PersistedKeybindingsState, +} from "./persisted-state"; + +export interface DecodedKeybindingsState { + keybindings: Map<ShortcutKey, TActionWithOptionalArgs>; + isCustomized: boolean; +} + +export function serializeKeybindingsState({ + keybindings, + isCustomized, +}: DecodedKeybindingsState): PersistedKeybindingsState { + return { + keybindings: Object.fromEntries(keybindings), + isCustomized, + }; +} + +export function migratePersistedKeybindingsState({ + state, + fromVersion, +}: { + state: unknown; + fromVersion: number; +}): unknown { + return runMigrations({ state, fromVersion }); +} + +/** + * Decode a persisted/migrated keybindings blob into the in-memory shape. + * + * Lossy by design: invalid entries are dropped and a warning is emitted, so the + * user falls back to (mostly) sensible defaults instead of a broken store. + * Returns `null` if the top-level shape is unrecognizable, in which case the + * caller should keep its current state. + */ +export function decodePersistedKeybindingsState({ + state, +}: { + state: unknown; +}): DecodedKeybindingsState | null { + const persisted = getPersistedKeybindingsState({ state }); + if (!persisted) { + console.warn( + "[keybindings] Persisted state has unexpected shape; keeping current keybindings.", + state, + ); + return null; + } + + const keybindings = new Map<ShortcutKey, TActionWithOptionalArgs>(); + const dropped: Array<{ key: string; action: string | undefined }> = []; + for (const [key, action] of Object.entries(persisted.keybindings)) { + if (action === undefined) continue; + if (!isShortcutKey(key) || !isActionWithOptionalArgs(action)) { + dropped.push({ key, action }); + continue; + } + + keybindings.set(key, action); + } + + if (dropped.length > 0) { + console.warn( + "[keybindings] Dropped invalid persisted entries:", + dropped, + ); + } + + return { + keybindings, + isCustomized: persisted.isCustomized, + }; +} + +/** + * Parse a user-supplied keybindings configuration (typically the output of + * `JSON.parse` on an imported file). + * + * Strict by design: throws on the first invalid entry so the caller can surface + * the failure to the user instead of silently producing a half-applied import. + * Accepts `unknown` because the input has already crossed a trust boundary. + */ +export function parseImportedKeybindings({ + config, +}: { + config: unknown; +}): Map<ShortcutKey, TActionWithOptionalArgs> { + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw new Error("Imported keybindings must be a JSON object"); + } + + const result = new Map<ShortcutKey, TActionWithOptionalArgs>(); + for (const [key, action] of Object.entries(config)) { + if (action === undefined) continue; + if (typeof action !== "string") { + throw new Error( + `Invalid action for "${key}": expected string, got ${typeof action}`, + ); + } + if (!isShortcutKey(key)) { + throw new Error(`Invalid shortcut key: ${JSON.stringify(key)}`); + } + if (!isActionWithOptionalArgs(action)) { + throw new Error( + `Invalid action for "${key}": ${JSON.stringify(action)}`, + ); + } + result.set(key, action); + } + return result; +} diff --git a/apps/web/src/actions/registry.ts b/apps/web/src/actions/registry.ts index e69b3b9f..ece14eaf 100644 --- a/apps/web/src/actions/registry.ts +++ b/apps/web/src/actions/registry.ts @@ -11,6 +11,7 @@ import type { type ActionHandler = (arg: unknown, trigger?: TInvocationTrigger) => void; const boundActions: Partial<Record<TAction, ActionHandler[]>> = {}; +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). export function bindAction<A extends TAction>( action: A, handler: TActionFunc<A>, @@ -24,6 +25,7 @@ export function bindAction<A extends TAction>( } } +// eslint-disable-next-line opencut/prefer-object-params -- action registries read best as (action, handler). export function unbindAction<A extends TAction>( action: A, handler: TActionFunc<A>, @@ -52,6 +54,7 @@ type InvokeActionFunc = { ): void; }; +// eslint-disable-next-line opencut/prefer-object-params -- dispatchers conventionally separate action, payload, and trigger. export const invokeAction: InvokeActionFunc = <A extends TAction>( action: A, args?: TArgOfAction<A>, diff --git a/apps/web/src/actions/types.ts b/apps/web/src/actions/types.ts index 41258c7b..38627b95 100644 --- a/apps/web/src/actions/types.ts +++ b/apps/web/src/actions/types.ts @@ -1,44 +1,44 @@ -import type { MutableRefObject } from "react";
-import type { TAction } from "./definitions";
-
-export type { TAction };
-
-export type TActionArgsMap = {
- "seek-forward": { seconds: number } | undefined;
- "seek-backward": { seconds: number } | undefined;
- "jump-forward": { seconds: number } | undefined;
- "jump-backward": { seconds: number } | undefined;
- "remove-media-asset": { projectId: string; assetId: string };
- "remove-media-assets": { projectId: string; assetIds: string[] };
-};
-
-type TKeysWithValueUndefined<T> = {
- [K in keyof T]: undefined extends T[K] ? K : never;
-}[keyof T];
-
-export type TActionWithArgs = keyof TActionArgsMap;
-
-export type TActionWithOptionalArgs =
- | TActionWithNoArgs
- | TKeysWithValueUndefined<TActionArgsMap>;
-
-export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>;
-
-export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
- ? TActionArgsMap[A]
- : undefined;
-
-export type TActionFunc<A extends TAction> = A extends TActionWithArgs
- ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
- : (_?: undefined, trigger?: TInvocationTrigger) => void;
-
-export type TInvocationTrigger = "keypress" | "mouseclick";
-
-export type TBoundActionList = {
- [A in TAction]?: Array<TActionFunc<A>>;
-};
-
-export type TActionHandlerOptions =
- | MutableRefObject<boolean>
- | boolean
- | undefined;
+import type { MutableRefObject } from "react"; +import type { TAction } from "./definitions"; + +export type { TAction }; + +export type TActionArgsMap = { + "seek-forward": { seconds: number } | undefined; + "seek-backward": { seconds: number } | undefined; + "jump-forward": { seconds: number } | undefined; + "jump-backward": { seconds: number } | undefined; + "remove-media-asset": { projectId: string; assetId: string }; + "remove-media-assets": { projectId: string; assetIds: string[] }; +}; + +type TKeysWithValueUndefined<T> = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +export type TActionWithArgs = keyof TActionArgsMap; + +export type TActionWithOptionalArgs = + | TActionWithNoArgs + | TKeysWithValueUndefined<TActionArgsMap>; + +export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>; + +export type TArgOfAction<A extends TAction> = A extends TActionWithArgs + ? TActionArgsMap[A] + : undefined; + +export type TActionFunc<A extends TAction> = A extends TActionWithArgs + ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void + : (_?: undefined, trigger?: TInvocationTrigger) => void; + +export type TInvocationTrigger = "keypress" | "mouseclick"; + +export type TBoundActionList = { + [A in TAction]?: Array<TActionFunc<A>>; +}; + +export type TActionHandlerOptions = + | MutableRefObject<boolean> + | boolean + | undefined; diff --git a/apps/web/src/actions/use-action-handler.ts b/apps/web/src/actions/use-action-handler.ts index a8bb0054..d4a37b50 100644 --- a/apps/web/src/actions/use-action-handler.ts +++ b/apps/web/src/actions/use-action-handler.ts @@ -8,6 +8,7 @@ import type { } from "@/actions"; import { bindAction, unbindAction } from "@/actions"; +// eslint-disable-next-line opencut/prefer-object-params -- action subscriptions read best as (action, handler, isActive). export function useActionHandler<A extends TAction>( action: A, handler: TActionFunc<A>, diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index e62a8ce0..e5d3a23b 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -1,11 +1,20 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { useTimelineStore } from "@/timeline/timeline-store"; import { useActionHandler } from "@/actions/use-action-handler"; import { useEditor } from "@/editor/use-editor"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + mediaTimeFromSeconds, + minMediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; import { getElementsAtTime, hasMediaId } from "@/timeline"; import { cancelInteraction } from "@/editor/cancel-interaction"; @@ -16,6 +25,7 @@ import { clearActiveScope, type ScopeEntry, } from "@/selection/scope"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; export function useEditorActions() { const editor = useEditor(); @@ -27,47 +37,34 @@ export function useEditorActions() { const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); - const hasTimelineSelectionRef = useRef(false); - const clearTimelineSelectionRef = useRef(() => {}); - const clearTimelineActiveSelectionRef = useRef(() => {}); - const timelineScopeRef = useRef<ScopeEntry | null>(null); const hasTimelineSelection = selectedElements.length > 0 || selectedKeyframes.length > 0 || selectedMaskPointSelection !== null; - - hasTimelineSelectionRef.current = hasTimelineSelection; - clearTimelineSelectionRef.current = () => { + const hasTimelineSelectionRef = useCommittedRef(hasTimelineSelection); + const clearTimelineSelectionRef = useCommittedRef(() => { editor.selection.clearSelection(); - }; - clearTimelineActiveSelectionRef.current = () => { + }); + const clearTimelineActiveSelectionRef = useCommittedRef(() => { editor.selection.clearMostSpecificSelection(); - }; - - if (!timelineScopeRef.current) { - timelineScopeRef.current = { - hasSelection: () => hasTimelineSelectionRef.current, - clear: () => { - clearTimelineSelectionRef.current(); - }, - clearActive: () => { - clearTimelineActiveSelectionRef.current(); - }, - }; - } + }); + const [timelineScope] = useState<ScopeEntry>(() => ({ + hasSelection: () => hasTimelineSelectionRef.current, + clear: () => { + clearTimelineSelectionRef.current(); + }, + clearActive: () => { + clearTimelineActiveSelectionRef.current(); + }, + })); useEffect(() => { if (!hasTimelineSelection) { return; } - const timelineScope = timelineScopeRef.current; - if (!timelineScope) { - return; - } - return activateScope({ entry: timelineScope }); - }, [hasTimelineSelection]); + }, [hasTimelineSelection, timelineScope]); useActionHandler( "toggle-play", @@ -83,7 +80,7 @@ export function useEditorActions() { if (editor.playback.getIsPlaying()) { editor.playback.toggle(); } - editor.playback.seek({ time: 0 }); + editor.playback.seek({ time: ZERO_MEDIA_TIME }); }, undefined, ); @@ -92,11 +89,15 @@ export function useEditorActions() { "seek-forward", (args) => { const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -106,8 +107,15 @@ export function useEditorActions() { "seek-backward", (args) => { const seconds = args?.seconds ?? 1; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -117,15 +125,20 @@ export function useEditorActions() { "frame-step-forward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); - editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + ticksPerFrame, + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, ), }); + editor.playback.seek({ + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), + }); }, undefined, ); @@ -134,11 +147,19 @@ export function useEditorActions() { "frame-step-backward", () => { const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ), + }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: ticksPerFrame, + }), + }), }); }, undefined, @@ -148,11 +169,15 @@ export function useEditorActions() { "jump-forward", (args) => { const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.min( - editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + seconds, - ), + time: minMediaTime({ + a: editor.timeline.getTotalDuration(), + b: addMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -162,8 +187,15 @@ export function useEditorActions() { "jump-backward", (args) => { const seconds = args?.seconds ?? 5; + const delta = mediaTimeFromSeconds({ seconds }); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - seconds), + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ + a: editor.playback.getCurrentTime(), + b: delta, + }), + }), }); }, undefined, @@ -172,7 +204,7 @@ export function useEditorActions() { useActionHandler( "goto-start", () => { - editor.playback.seek({ time: 0 }); + editor.playback.seek({ time: ZERO_MEDIA_TIME }); }, undefined, ); @@ -273,7 +305,7 @@ export function useEditorActions() { if (!selectedMaskPointSelection) { return; } - editor.timeline.deleteCustomMaskPoints({ + editor.timeline.deleteFreeformPathMaskPoints({ trackId: selectedMaskPointSelection.trackId, elementId: selectedMaskPointSelection.elementId, maskId: selectedMaskPointSelection.maskId, diff --git a/apps/web/src/actions/use-keybindings.ts b/apps/web/src/actions/use-keybindings.ts index c129795c..e9d78982 100644 --- a/apps/web/src/actions/use-keybindings.ts +++ b/apps/web/src/actions/use-keybindings.ts @@ -33,7 +33,7 @@ export function useKeybindingsListener() { const isTextInput = activeElement instanceof HTMLElement && isTypableDOMElement({ element: activeElement }); - const boundAction = binding ? keybindings[binding] : undefined; + const boundAction = binding ? keybindings.get(binding) : undefined; if (normalizedKey === "escape" && isTextInput) { activeElement.blur(); diff --git a/apps/web/src/actions/use-keyboard-shortcuts-help.ts b/apps/web/src/actions/use-keyboard-shortcuts-help.ts index 7681e664..89a17887 100644 --- a/apps/web/src/actions/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/actions/use-keyboard-shortcuts-help.ts @@ -39,27 +39,21 @@ export function useKeyboardShortcutsHelp() { const { keybindings } = useKeybindingsStore(); const shortcuts = useMemo(() => { - const result: KeyboardShortcut[] = []; - const actionToKeys: Partial<Record<TActionWithOptionalArgs, string[]>> = {}; + const actionToKeys = new Map<TActionWithOptionalArgs, string[]>(); - for (const [key, action] of Object.entries(keybindings) as Array< - [string, TActionWithOptionalArgs | undefined] - >) { - if (action) { - if (!actionToKeys[action]) { - actionToKeys[action] = []; - } - actionToKeys[action].push(formatKey({ key })); + for (const [key, action] of keybindings) { + const existing = actionToKeys.get(action); + if (existing) { + existing.push(formatKey({ key })); + } else { + actionToKeys.set(action, [formatKey({ key })]); } } - for (const [action, keys] of Object.entries(actionToKeys) as Array< - [TActionWithOptionalArgs, string[]] - >) { + const result: KeyboardShortcut[] = []; + for (const [action, keys] of actionToKeys) { const actionDef = ACTIONS[action]; - if (!actionDef) { - continue; - } + if (!actionDef) continue; result.push({ id: action, keys, diff --git a/apps/web/src/animation/__tests__/animated-params.test.ts b/apps/web/src/animation/__tests__/animated-params.test.ts index 7b9d312d..a47b0382 100644 --- a/apps/web/src/animation/__tests__/animated-params.test.ts +++ b/apps/web/src/animation/__tests__/animated-params.test.ts @@ -1,15 +1,15 @@ import { describe, expect, test } from "bun:test"; import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamNumericRange, - getAnimationParamValueKind, -} from "@/animation/animated-params"; + coerceParamValue, + getParamDefaultInterpolation, + getParamNumericRange, + getParamValueKind, +} from "@/params"; describe("animated params", () => { test("snaps and clamps number params", () => { expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "intensity", label: "Intensity", @@ -24,7 +24,7 @@ describe("animated params", () => { ).toBe(0.5); expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "intensity", label: "Intensity", @@ -49,14 +49,14 @@ describe("animated params", () => { max: 1, step: 0.25, }; - expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: true })).toBeNull(); + expect(coerceParamValue({ param, value: Number.NaN })).toBeNull(); + expect(coerceParamValue({ param, value: "0.5" })).toBeNull(); + expect(coerceParamValue({ param, value: true })).toBeNull(); }); test("passthrough with step <= 0 guard", () => { expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "x", label: "X", @@ -81,13 +81,13 @@ describe("animated params", () => { { value: "multiply", label: "Multiply" }, ], }; - expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal"); - expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply"); + expect(coerceParamValue({ param, value: "normal" })).toBe("normal"); + expect(coerceParamValue({ param, value: "multiply" })).toBe("multiply"); }); test("rejects select values outside the allowed options", () => { expect( - coerceAnimationParamValue({ + coerceParamValue({ param: { key: "blend", label: "Blend", @@ -111,9 +111,9 @@ describe("animated params", () => { default: "normal", options: [{ value: "normal", label: "Normal" }], }; - expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull(); + expect(coerceParamValue({ param, value: 42 })).toBeNull(); + expect(coerceParamValue({ param, value: null })).toBeNull(); + expect(coerceParamValue({ param, value: undefined })).toBeNull(); }); test("boolean params accept booleans and reject other types", () => { @@ -123,10 +123,10 @@ describe("animated params", () => { type: "boolean" as const, default: true, }; - expect(coerceAnimationParamValue({ param, value: true })).toBe(true); - expect(coerceAnimationParamValue({ param, value: false })).toBe(false); - expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull(); + expect(coerceParamValue({ param, value: true })).toBe(true); + expect(coerceParamValue({ param, value: false })).toBe(false); + expect(coerceParamValue({ param, value: 1 })).toBeNull(); + expect(coerceParamValue({ param, value: "true" })).toBeNull(); }); test("color params accept strings and reject other types", () => { @@ -136,14 +136,14 @@ describe("animated params", () => { type: "color" as const, default: "#ffffff", }; - expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000"); - expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull(); - expect(coerceAnimationParamValue({ param, value: null })).toBeNull(); + expect(coerceParamValue({ param, value: "#ff0000" })).toBe("#ff0000"); + expect(coerceParamValue({ param, value: 0xff0000 })).toBeNull(); + expect(coerceParamValue({ param, value: null })).toBeNull(); }); test("getAnimationParamValueKind maps param type to binding kind", () => { expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "n", label: "N", @@ -155,17 +155,17 @@ describe("animated params", () => { }), ).toBe("number"); expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "c", label: "C", type: "color", default: "#fff" }, }), ).toBe("color"); expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "b", label: "B", type: "boolean", default: false }, }), ).toBe("discrete"); expect( - getAnimationParamValueKind({ + getParamValueKind({ param: { key: "s", label: "S", @@ -179,7 +179,7 @@ describe("animated params", () => { test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => { expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "n", label: "N", @@ -191,17 +191,17 @@ describe("animated params", () => { }), ).toBe("linear"); expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "c", label: "C", type: "color", default: "#fff" }, }), ).toBe("linear"); expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "b", label: "B", type: "boolean", default: false }, }), ).toBe("hold"); expect( - getAnimationParamDefaultInterpolation({ + getParamDefaultInterpolation({ param: { key: "s", label: "S", @@ -215,7 +215,7 @@ describe("animated params", () => { test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => { expect( - getAnimationParamNumericRange({ + getParamNumericRange({ param: { key: "intensity", label: "Intensity", @@ -228,12 +228,12 @@ describe("animated params", () => { }), ).toEqual({ min: 0, max: 1, step: 0.1 }); expect( - getAnimationParamNumericRange({ + getParamNumericRange({ param: { key: "c", label: "C", type: "color", default: "#fff" }, }), ).toBeUndefined(); expect( - getAnimationParamNumericRange({ + getParamNumericRange({ param: { key: "b", label: "B", type: "boolean", default: false }, }), ).toBeUndefined(); diff --git a/apps/web/src/animation/__tests__/binding-values.test.ts b/apps/web/src/animation/__tests__/binding-values.test.ts deleted file mode 100644 index a1f2010c..00000000 --- a/apps/web/src/animation/__tests__/binding-values.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - composeAnimationValue, - createAnimationBinding, -} from "@/animation/binding-values"; - -describe("binding values", () => { - test("formats composed animated colors as hex", () => { - const binding = createAnimationBinding({ - path: "color", - kind: "color", - }); - - expect( - composeAnimationValue({ - binding, - componentValues: { - r: 1, - g: 0, - b: 0, - a: 1, - }, - }), - ).toBe("#ff0000"); - }); -}); diff --git a/apps/web/src/animation/animated-params.ts b/apps/web/src/animation/animated-params.ts deleted file mode 100644 index bc27b15d..00000000 --- a/apps/web/src/animation/animated-params.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { snapToStep } from "@/utils/math"; -import type { ParamDefinition } from "@/params"; -import type { DynamicAnimationPathValue, NumericSpec } from "./types"; - -export function getAnimationParamValueKind({ - param, -}: { - param: ParamDefinition; -}): "number" | "color" | "discrete" { - if (param.type === "number") { - return "number"; - } - - if (param.type === "color") { - return "color"; - } - - return "discrete"; -} - -export function getAnimationParamDefaultInterpolation({ - param, -}: { - param: ParamDefinition; -}): "linear" | "hold" { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; -} - -export function getAnimationParamNumericRange({ - param, -}: { - param: ParamDefinition; -}): NumericSpec | undefined { - if (param.type !== "number") { - return undefined; - } - - return { - min: param.min, - max: param.max, - step: param.step, - }; -} - -/** - * `coerceAnimationParamValue` accepts `unknown` rather than a narrow - * `DynamicAnimationPathValue` because it doubles as a runtime gate for - * untrusted inputs (persisted state, paste payloads, programmatic updates). - * The caller does not need to pre-validate; null means "this value cannot live - * on this param". - */ -export function coerceAnimationParamValue({ - param, - value, -}: { - param: ParamDefinition; - value: unknown; -}): DynamicAnimationPathValue | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const minValue = param.min; - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); - } - - if (param.type === "color") { - return typeof value === "string" ? value : null; - } - - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (typeof value !== "string") { - return null; - } - - return param.options.some((option) => option.value === value) ? value : null; -} diff --git a/apps/web/src/animation/binding-values.ts b/apps/web/src/animation/binding-values.ts deleted file mode 100644 index a46aa9a6..00000000 --- a/apps/web/src/animation/binding-values.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { converter, formatHex, formatHex8, parse } from "culori"; -import type { - AnimationBindingComponent, - AnimationBindingOfKind, - AnimationBindingInstance, - AnimationBindingKind, - ColorAnimationBinding, - DiscreteAnimationBinding, - NumberAnimationBinding, - AnimationPath, - AnimationValue, - DiscreteValue, - Vector2AnimationBinding, - VectorValue, -} from "@/animation/types"; -import { clamp } from "@/utils/math"; - -interface LinearRgba { - r: number; - g: number; - b: number; - a: number; -} - -export type AnimationComponentValue = number | DiscreteValue; - -const toRgb = converter("rgb"); - -function srgbToLinear({ value }: { value: number }): number { - return value <= 0.04045 - ? value / 12.92 - : Math.pow((value + 0.055) / 1.055, 2.4); -} - -function linearToSrgb({ value }: { value: number }): number { - const clamped = clamp({ value, min: 0, max: 1 }); - return clamped <= 0.0031308 - ? clamped * 12.92 - : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; -} - -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null; -} - -export function isVectorValue(value: unknown): value is VectorValue { - return isRecord(value) && typeof value.x === "number" && typeof value.y === "number"; -} - -export type EasingMode = "independent" | "shared"; - -/** - * Declares how easing curves apply to a binding's components. - * "shared" means all components always use the same curve (e.g. color — you never - * want to ease R independently from G/B/A). "independent" means each component - * can have its own curve. - */ -export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode { - return kind === "color" ? "shared" : "independent"; -} - -export function getBindingComponentKeys({ - kind, -}: { - kind: AnimationBindingKind; -}): string[] { - if (kind === "vector2") { - return ["x", "y"]; - } - - if (kind === "color") { - return ["r", "g", "b", "a"]; - } - - return ["value"]; -} - -export function buildBindingChannelId({ - path, - componentKey, -}: { - path: AnimationPath; - componentKey: string; -}): string { - return `${path}:${componentKey}`; -} - -function createBindingComponent<TKey extends string>({ - path, - key, -}: { - path: AnimationPath; - key: TKey; -}): AnimationBindingComponent<TKey> { - return { - key, - channelId: buildBindingChannelId({ path, componentKey: key }), - }; -} - -function cloneBindingComponents<TKey extends string>({ - components, -}: { - components: AnimationBindingComponent<TKey>[]; -}): AnimationBindingComponent<TKey>[] { - return components.map((component) => ({ ...component })); -} - -const animationBindingFactories = { - color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({ - path, - kind: "color", - colorSpace: "srgb-linear", - components: [ - createBindingComponent({ path, key: "r" }), - createBindingComponent({ path, key: "g" }), - createBindingComponent({ path, key: "b" }), - createBindingComponent({ path, key: "a" }), - ], - }), - vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({ - path, - kind: "vector2", - components: [ - createBindingComponent({ path, key: "x" }), - createBindingComponent({ path, key: "y" }), - ], - }), - number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({ - path, - kind: "number", - components: [createBindingComponent({ path, key: "value" })], - }), - discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({ - path, - kind: "discrete", - components: [createBindingComponent({ path, key: "value" })], - }), -} satisfies { - [K in AnimationBindingKind]: ({ - path, - }: { - path: AnimationPath; - }) => AnimationBindingOfKind<K>; -}; - -export function createAnimationBinding<TKind extends AnimationBindingKind>({ - path, - kind, -}: { - path: AnimationPath; - kind: TKind; -}): AnimationBindingOfKind<TKind>; -export function createAnimationBinding({ - path, - kind, -}: { - path: AnimationPath; - kind: AnimationBindingKind; -}): AnimationBindingInstance { - return animationBindingFactories[kind]({ path }); -} - -const animationBindingCloners = { - color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - vector2: ({ - binding, - }: { - binding: Vector2AnimationBinding; - }): Vector2AnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - number: ({ - binding, - }: { - binding: NumberAnimationBinding; - }): NumberAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), - discrete: ({ - binding, - }: { - binding: DiscreteAnimationBinding; - }): DiscreteAnimationBinding => ({ - ...binding, - components: cloneBindingComponents({ - components: binding.components, - }), - }), -} satisfies { - [K in AnimationBindingKind]: ({ - binding, - }: { - binding: AnimationBindingOfKind<K>; - }) => AnimationBindingOfKind<K>; -}; - -export function cloneAnimationBinding<TKind extends AnimationBindingKind>({ - binding, -}: { - binding: AnimationBindingOfKind<TKind>; -}): AnimationBindingOfKind<TKind>; -export function cloneAnimationBinding({ - binding, -}: { - binding: AnimationBindingInstance; -}): AnimationBindingInstance { - switch (binding.kind) { - case "color": - return animationBindingCloners.color({ binding }); - case "vector2": - return animationBindingCloners.vector2({ binding }); - case "number": - return animationBindingCloners.number({ binding }); - case "discrete": - return animationBindingCloners.discrete({ binding }); - } -} - -export function parseColorToLinearRgba({ - color, -}: { - color: string; -}): LinearRgba | null { - const parsed = parse(color); - const rgb = parsed ? toRgb(parsed) : null; - if (!rgb) { - return null; - } - - return { - r: srgbToLinear({ value: rgb.r ?? 0 }), - g: srgbToLinear({ value: rgb.g ?? 0 }), - b: srgbToLinear({ value: rgb.b ?? 0 }), - a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }), - }; -} - -export function formatLinearRgba({ - color, -}: { - color: LinearRgba; -}): string { - const rgb = { - mode: "rgb", - r: linearToSrgb({ value: color.r }), - g: linearToSrgb({ value: color.g }), - b: linearToSrgb({ value: color.b }), - alpha: clamp({ value: color.a, min: 0, max: 1 }), - } as const; - return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); -} - -export function decomposeAnimationValue({ - kind, - value, -}: { - kind: AnimationBindingKind; - value: AnimationValue; -}): Record<string, AnimationComponentValue> | null { - if (kind === "number") { - return typeof value === "number" ? { value } : null; - } - - if (kind === "vector2") { - return isVectorValue(value) ? { x: value.x, y: value.y } : null; - } - - if (kind === "color") { - if (typeof value !== "string") { - return null; - } - const parsed = parseColorToLinearRgba({ color: value }); - if (!parsed) { - return null; - } - return { - r: parsed.r, - g: parsed.g, - b: parsed.b, - a: parsed.a, - }; - } - - return typeof value === "string" || typeof value === "boolean" - ? { value } - : null; -} - -export function composeAnimationValue({ - binding, - componentValues, -}: { - binding: AnimationBindingInstance; - componentValues: Record<string, AnimationComponentValue | undefined>; -}): AnimationValue | null { - if (binding.kind === "number") { - const value = componentValues.value; - return typeof value === "number" ? value : null; - } - - if (binding.kind === "vector2") { - const x = componentValues.x; - const y = componentValues.y; - return typeof x === "number" && typeof y === "number" ? { x, y } : null; - } - - if (binding.kind === "color") { - const r = componentValues.r; - const g = componentValues.g; - const b = componentValues.b; - const a = componentValues.a; - if ( - typeof r !== "number" || - typeof g !== "number" || - typeof b !== "number" || - typeof a !== "number" - ) { - return null; - } - return formatLinearRgba({ - color: { - r, - g, - b, - a, - }, - }); - } - - const value = componentValues.value; - return typeof value === "string" || typeof value === "boolean" ? value : null; -} diff --git a/apps/web/src/animation/channel-data.ts b/apps/web/src/animation/channel-data.ts new file mode 100644 index 00000000..2fc08b5c --- /dev/null +++ b/apps/web/src/animation/channel-data.ts @@ -0,0 +1,57 @@ +import type { + AnimationChannel, + ChannelData, + CompositeChannelData, +} from "@/animation/types"; + +const LEGACY_ANIMATION_STORAGE_KEYS = new Set(["bindings", "channels"]); + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + +export function isLeafChannelData( + data: ChannelData | undefined, +): data is AnimationChannel { + return isRecord(data) && Array.isArray(data.keys); +} + +export function isCompositeChannelData( + data: ChannelData | undefined, +): data is CompositeChannelData { + return isRecord(data) && !Array.isArray(data.keys); +} + +export function getChannelsFromData({ + data, +}: { + data: ChannelData | undefined; +}): AnimationChannel[] { + if (isLeafChannelData(data)) { + return [data]; + } + if (!isCompositeChannelData(data)) { + return []; + } + return Object.values(data).filter(isLeafChannelData); +} + +export function getChannelEntriesFromData({ + data, +}: { + data: ChannelData | undefined; +}): Array<[string, AnimationChannel]> { + if (isLeafChannelData(data)) { + return [["value", data]]; + } + if (!isCompositeChannelData(data)) { + return []; + } + return Object.entries(data).flatMap(([componentKey, channel]) => + isLeafChannelData(channel) ? [[componentKey, channel]] : [], + ); +} + +export function isAnimationStorageKey({ key }: { key: string }): boolean { + return !LEGACY_ANIMATION_STORAGE_KEYS.has(key); +} diff --git a/apps/web/src/animation/curve-bridge.ts b/apps/web/src/animation/curve-bridge.ts index af411dd1..c947e7dc 100644 --- a/apps/web/src/animation/curve-bridge.ts +++ b/apps/web/src/animation/curve-bridge.ts @@ -7,6 +7,7 @@ import type { NormalizedCubicBezier, ScalarAnimationKey, } from "@/animation/types"; +import { roundMediaTime } from "@/wasm"; const VALUE_EPSILON = 1e-6; @@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({ return { rightHandle: { - dt: spanTime * x1, + dt: roundMediaTime({ time: spanTime * x1 }), dv: effectiveSpanValue * y1, }, leftHandle: { - dt: spanTime * (x2 - 1), + dt: roundMediaTime({ time: spanTime * (x2 - 1) }), dv: effectiveSpanValue * (y2 - 1), }, }; diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts index e1c442f7..3def2109 100644 --- a/apps/web/src/animation/effect-param-channel.ts +++ b/apps/web/src/animation/effect-param-channel.ts @@ -70,7 +70,7 @@ export function resolveEffectParamsAtTime({ for (const [paramKey, staticValue] of Object.entries(params)) { const path = buildEffectParamPath({ effectId, paramKey }); - resolved[paramKey] = animations?.bindings[path] + resolved[paramKey] = animations?.[path] ? resolveAnimationPathValueAtTime({ animations, propertyPath: path, diff --git a/apps/web/src/animation/graph-channels.ts b/apps/web/src/animation/graph-channels.ts index d6482602..cef1397a 100644 --- a/apps/web/src/animation/graph-channels.ts +++ b/apps/web/src/animation/graph-channels.ts @@ -1,21 +1,35 @@ import type { - AnimationBindingInstance, AnimationPath, ElementAnimations, + ChannelData, ScalarAnimationChannel, ScalarGraphChannel, ScalarGraphKeyframeContext, } from "@/animation/types"; +import type { ChannelEasingMode } from "@/params"; +import { isCompositeChannelData, isLeafChannelData } from "./channel-data"; +import { isScalarChannel } from "./interpolation"; export interface EditableScalarChannels { - binding: AnimationBindingInstance; + easingMode: ChannelEasingMode; channels: ScalarGraphChannel[]; } function isScalarAnimationChannel( - channel: ElementAnimations["channels"][string], + channel: ChannelData | undefined, ): channel is ScalarAnimationChannel { - return channel?.kind === "scalar"; + return isLeafChannelData(channel) && isScalarChannel(channel); +} + +function getEasingModeForChannelData({ + data, +}: { + data: ChannelData | undefined; +}): ChannelEasingMode { + return isCompositeChannelData(data) && + ["r", "g", "b", "a"].every((componentKey) => componentKey in data) + ? "shared" + : "independent"; } export function getEditableScalarChannels({ @@ -25,13 +39,15 @@ export function getEditableScalarChannels({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; }): EditableScalarChannels | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + const data = animations?.[propertyPath]; + if (!data) { return null; } - const channels = binding.components.flatMap((component) => { - const channel = animations?.channels[component.channelId]; + const channelEntries = isLeafChannelData(data) + ? [["value", data] as const] + : Object.entries(data); + const channels = channelEntries.flatMap(([componentKey, channel]) => { if (!isScalarAnimationChannel(channel)) { return []; } @@ -39,14 +55,13 @@ export function getEditableScalarChannels({ return [ { propertyPath, - componentKey: component.key, - channelId: component.channelId, + componentKey, channel, } satisfies ScalarGraphChannel, ]; }); - return { binding, channels }; + return { easingMode: getEasingModeForChannelData({ data }), channels }; } export function getEditableScalarChannel({ diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts index d0bfdded..32f84714 100644 --- a/apps/web/src/animation/graphic-param-channel.ts +++ b/apps/web/src/animation/graphic-param-channel.ts @@ -49,7 +49,7 @@ export function resolveGraphicParamsAtTime({ for (const param of definitions) { const path = buildGraphicParamPath({ paramKey: param.key }); - if (!animations?.bindings[path]) { + if (!animations?.[path]) { continue; } diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts index af3b910d..f3d8aca4 100644 --- a/apps/web/src/animation/index.ts +++ b/apps/web/src/animation/index.ts @@ -65,10 +65,8 @@ export { } from "./property-groups"; export { - type EasingMode, - getEasingModeForKind, -} from "./binding-values"; - -export { isAnimationPath, + isAnimationPropertyPath, } from "./path"; + +export type { NumericSpec } from "./types"; diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts index 6bab66c4..cdb8bcc9 100644 --- a/apps/web/src/animation/interpolation.ts +++ b/apps/web/src/animation/interpolation.ts @@ -1,13 +1,15 @@ import type { AnimationChannel, AnimationInterpolation, - AnimationValue, + Channel, DiscreteAnimationChannel, DiscreteValue, ScalarAnimationChannel, ScalarAnimationKey, ScalarSegmentType, } from "@/animation/types"; +import type { ParamValue } from "@/params"; +import { mediaTime } from "@/wasm"; import { getBezierPoint, getDefaultLeftHandle, @@ -63,9 +65,13 @@ function normalizeRightHandle({ return undefined; } - const span = Math.max(1, rightKey.time - leftKey.time); + const span = mediaTime({ + ticks: Math.max(1, rightKey.time - leftKey.time), + }); return { - dt: Math.min(span, Math.max(0, handle.dt)), + dt: mediaTime({ + ticks: Math.min(span, Math.max(0, handle.dt)), + }), dv: handle.dv, }; } @@ -83,9 +89,13 @@ function normalizeLeftHandle({ return undefined; } - const span = Math.max(1, rightKey.time - leftKey.time); + const span = mediaTime({ + ticks: Math.max(1, rightKey.time - leftKey.time), + }); return { - dt: Math.max(-span, Math.min(0, handle.dt)), + dt: mediaTime({ + ticks: Math.max(-span, Math.min(0, handle.dt)), + }), dv: handle.dv, }; } @@ -102,7 +112,7 @@ function normalizeScalarKey({ }; } -function normalizeScalarChannel({ +export function normalizeScalarChannel({ channel, }: { channel: ScalarAnimationChannel; @@ -145,17 +155,11 @@ function normalizeScalarChannel({ }; } -export function normalizeChannel<TChannel extends AnimationChannel>({ +export function normalizeDiscreteChannel({ channel, }: { - channel: TChannel; -}): TChannel { - if (channel.kind === "scalar") { - return normalizeScalarChannel({ - channel, - }) as TChannel; - } - + channel: DiscreteAnimationChannel; +}): DiscreteAnimationChannel { return { ...channel, keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => @@ -164,7 +168,39 @@ export function normalizeChannel<TChannel extends AnimationChannel>({ rightTime: rightKeyframe.time, }), ), - } as TChannel; + }; +} + +export function isScalarChannel(channel: AnimationChannel): channel is ScalarAnimationChannel { + return ( + "extrapolation" in channel || + channel.keys.some((keyframe) => "segmentToNext" in keyframe) + ); +} + +export function normalizeChannel({ + channel, +}: { + channel: ScalarAnimationChannel; +}): ScalarAnimationChannel; +export function normalizeChannel({ + channel, +}: { + channel: DiscreteAnimationChannel; +}): DiscreteAnimationChannel; +export function normalizeChannel({ + channel, +}: { + channel: AnimationChannel; +}): AnimationChannel; +export function normalizeChannel({ + channel, +}: { + channel: AnimationChannel; +}): AnimationChannel { + return isScalarChannel(channel) + ? normalizeScalarChannel({ channel }) + : normalizeDiscreteChannel({ channel }); } function extrapolateScalarEdge({ @@ -207,7 +243,7 @@ export function getScalarChannelValueAtTime({ time, fallbackValue, }: { - channel: ScalarAnimationChannel | undefined; + channel: Channel<number> | undefined; time: number; fallbackValue: number; }): number { @@ -215,9 +251,7 @@ export function getScalarChannelValueAtTime({ return fallbackValue; } - const normalizedChannel = normalizeChannel({ - channel, - }); + const normalizedChannel = normalizeScalarChannel({ channel }); const firstKey = normalizedChannel.keys[0]; const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; if (!firstKey || !lastKey) { @@ -319,7 +353,7 @@ export function getDiscreteChannelValueAtTime({ time, fallbackValue, }: { - channel: DiscreteAnimationChannel | undefined; + channel: Channel<DiscreteValue> | undefined; time: number; fallbackValue: DiscreteValue; }): DiscreteValue { @@ -327,9 +361,7 @@ export function getDiscreteChannelValueAtTime({ return fallbackValue; } - const normalizedChannel = normalizeChannel({ - channel, - }); + const normalizedChannel = normalizeDiscreteChannel({ channel }); let currentValue = fallbackValue; for (const key of normalizedChannel.keys) { if (time < key.time) { @@ -345,16 +377,34 @@ export function getChannelValueAtTime({ time, fallbackValue, }: { + channel: Channel<number> | undefined; + time: number; + fallbackValue: number; +}): number; +export function getChannelValueAtTime<TValue extends DiscreteValue>({ + channel, + time, + fallbackValue, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + fallbackValue: TValue; +}): TValue; +export function getChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { channel: AnimationChannel | undefined; time: number; - fallbackValue: AnimationValue; -}): AnimationValue { + fallbackValue: ParamValue; +}): ParamValue { if (!channel || channel.keys.length === 0) { return fallbackValue; } - if (channel.kind === "scalar") { - return typeof fallbackValue === "number" + if (typeof fallbackValue === "number") { + return isScalarChannel(channel) ? getScalarChannelValueAtTime({ channel, time, @@ -368,7 +418,7 @@ export function getChannelValueAtTime({ } return getDiscreteChannelValueAtTime({ - channel, + channel: isScalarChannel(channel) ? undefined : channel, time, fallbackValue, }); diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts index e65db881..95f7fcf0 100644 --- a/apps/web/src/animation/keyframe-query.ts +++ b/apps/web/src/animation/keyframe-query.ts @@ -1,75 +1,75 @@ import type { - AnimationBindingInstance, AnimationChannel, AnimationPath, + ChannelData, ElementAnimations, ElementKeyframe, } from "@/animation/types"; +import { formatLinearRgba } from "@/params"; import { - type AnimationComponentValue, - composeAnimationValue, -} from "./binding-values"; + getChannelEntriesFromData, + isAnimationStorageKey, +} from "./channel-data"; import { getChannelValueAtTime, getScalarSegmentInterpolation, + isScalarChannel, } from "./interpolation"; import { isAnimationPath } from "./path"; -function getBindingFallbackValue({ +function getChannelFallbackValue({ channel, }: { - channel: ElementAnimations["channels"][string]; + channel: AnimationChannel; }) { - if (!channel || channel.keys.length === 0) { - return channel?.kind === "discrete" ? false : 0; + if (channel.keys.length === 0) { + return isScalarChannel(channel) ? 0 : false; } return channel.keys[0].value; } -interface BindingKeyframeMatch { +interface ChannelKeyframeMatch { + componentKey: string; componentIndex: number; channel: AnimationChannel; keyframe: AnimationChannel["keys"][number]; } -function getBindingKeyframeMatches({ - animations, - binding, +function getChannelKeyframeMatches({ + data, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; -}): BindingKeyframeMatch[] { - return binding.components.flatMap((component, componentIndex) => { - const channel = animations.channels[component.channelId]; - if (!channel || channel.keys.length === 0) { - return []; - } + data: ChannelData | undefined; +}): ChannelKeyframeMatch[] { + return getChannelEntriesFromData({ data }).flatMap( + ([componentKey, channel], componentIndex) => { + if (channel.keys.length === 0) { + return []; + } - return channel.keys.map((keyframe) => ({ - componentIndex, - channel, - keyframe, - })); - }); + return channel.keys.map((keyframe) => ({ + componentKey, + componentIndex, + channel, + keyframe, + })); + }, + ); } -function getUniqueBindingKeyframeMatches({ - animations, - binding, +function getUniqueChannelKeyframeMatches({ + data, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; -}): BindingKeyframeMatch[] { - const sortedMatches = getBindingKeyframeMatches({ - animations, - binding, + data: ChannelData | undefined; +}): ChannelKeyframeMatch[] { + const sortedMatches = getChannelKeyframeMatches({ + data, }).sort( (leftMatch, rightMatch) => leftMatch.keyframe.time - rightMatch.keyframe.time || leftMatch.componentIndex - rightMatch.componentIndex, ); - const uniqueMatches: BindingKeyframeMatch[] = []; + const uniqueMatches: ChannelKeyframeMatch[] = []; for (const match of sortedMatches) { const previousMatch = uniqueMatches[uniqueMatches.length - 1]; @@ -92,11 +92,11 @@ function getUniqueBindingKeyframeMatches({ return uniqueMatches; } -function getPreferredBindingKeyframeMatch({ +function getPreferredChannelKeyframeMatch({ matches, }: { - matches: BindingKeyframeMatch[]; -}): BindingKeyframeMatch | null { + matches: ChannelKeyframeMatch[]; +}): ChannelKeyframeMatch | null { return ( matches.find((match) => match.componentIndex === 0) ?? matches[0] ?? @@ -104,35 +104,67 @@ function getPreferredBindingKeyframeMatch({ ); } -function getComposedBindingValueAtTime({ - animations, - binding, +function getChannelValue({ + channel, time, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; + channel: AnimationChannel; time: number; }) { - const componentValues = Object.fromEntries( - binding.components.map((component) => { - const channel = animations.channels[component.channelId]; - return [ - component.key, - getChannelValueAtTime({ - channel, - time, - fallbackValue: getBindingFallbackValue({ channel }), - }), - ]; - }), - ) as Record<string, AnimationComponentValue | undefined>; - - return composeAnimationValue({ - binding, - componentValues, + const fallbackValue = getChannelFallbackValue({ channel }); + if (typeof fallbackValue === "number") { + return getChannelValueAtTime({ + channel: isScalarChannel(channel) ? channel : undefined, + time, + fallbackValue, + }); + } + return getChannelValueAtTime({ + channel: !isScalarChannel(channel) ? channel : undefined, + time, + fallbackValue, }); } +function getComposedChannelDataValueAtTime({ + data, + time, +}: { + data: ChannelData | undefined; + time: number; +}) { + const entries = getChannelEntriesFromData({ data }); + if (entries.length === 0) { + return null; + } + if (entries.length === 1 && entries[0]?.[0] === "value") { + return getChannelValue({ channel: entries[0][1], time }); + } + + const componentValues = Object.fromEntries( + entries.map(([componentKey, channel]) => [ + componentKey, + getChannelValue({ channel, time }), + ]), + ); + if ( + typeof componentValues.r === "number" && + typeof componentValues.g === "number" && + typeof componentValues.b === "number" && + typeof componentValues.a === "number" + ) { + return formatLinearRgba({ + color: { + r: componentValues.r, + g: componentValues.g, + b: componentValues.b, + a: componentValues.a, + }, + }); + } + return null; +} + function getKeyframeInterpolation({ channel, keyframe, @@ -140,25 +172,22 @@ function getKeyframeInterpolation({ channel: AnimationChannel; keyframe: AnimationChannel["keys"][number]; }) { - return channel.kind === "scalar" && "segmentToNext" in keyframe + return isScalarChannel(channel) && "segmentToNext" in keyframe ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) : "hold"; } function toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }: { - animations: ElementAnimations; - binding: AnimationBindingInstance; + data: ChannelData | undefined; propertyPath: AnimationPath; - keyframeMatch: BindingKeyframeMatch; + keyframeMatch: ChannelKeyframeMatch; }): ElementKeyframe | null { - const value = getComposedBindingValueAtTime({ - animations, - binding, + const value = getComposedChannelDataValueAtTime({ + data, time: keyframeMatch.keyframe.time, }); if (value === null) { @@ -186,19 +215,19 @@ export function getElementKeyframes({ return []; } - return Object.entries(animations.bindings).flatMap( - ([propertyPath, binding]) => { - if (!binding || !isAnimationPath(propertyPath)) { + return Object.entries(animations).filter(([key]) => + isAnimationStorageKey({ key }), + ).flatMap( + ([propertyPath, data]) => { + if (!data || !isAnimationPath(propertyPath)) { return []; } - return getUniqueBindingKeyframeMatches({ - animations, - binding, + return getUniqueChannelKeyframeMatches({ + data, }).flatMap((keyframeMatch) => { const keyframe = toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }); @@ -219,13 +248,8 @@ export function hasKeyframesForPath({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; }): boolean { - const binding = animations?.bindings[propertyPath]; - if (!binding) { - return false; - } - - return binding.components.some((component) => - Boolean(animations?.channels[component.channelId]?.keys.length), + return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some( + ([, channel]) => channel.keys.length > 0, ); } @@ -238,24 +262,22 @@ export function getKeyframeAtTime({ propertyPath: AnimationPath; time: number; }): ElementKeyframe | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + const data = animations?.[propertyPath]; + if (!data) { return null; } - const keyframeMatch = getPreferredBindingKeyframeMatch({ - matches: getBindingKeyframeMatches({ - animations, - binding, - }).filter(({ keyframe }) => keyframe.time === time), + const keyframeMatch = getPreferredChannelKeyframeMatch({ + matches: getChannelKeyframeMatches({ data }).filter( + ({ keyframe }) => keyframe.time === time, + ), }); if (!keyframeMatch) { return null; } return toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }); @@ -270,24 +292,22 @@ export function getKeyframeById({ propertyPath: AnimationPath; keyframeId: string; }): ElementKeyframe | null { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + const data = animations?.[propertyPath]; + if (!data) { return null; } - const keyframeMatch = getPreferredBindingKeyframeMatch({ - matches: getBindingKeyframeMatches({ - animations, - binding, - }).filter(({ keyframe }) => keyframe.id === keyframeId), + const keyframeMatch = getPreferredChannelKeyframeMatch({ + matches: getChannelKeyframeMatches({ data }).filter( + ({ keyframe }) => keyframe.id === keyframeId, + ), }); if (!keyframeMatch) { return null; } return toElementKeyframe({ - animations, - binding, + data, propertyPath, keyframeMatch, }); diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts index ff0cef8f..c3fd8202 100644 --- a/apps/web/src/animation/keyframes.ts +++ b/apps/web/src/animation/keyframes.ts @@ -1,10 +1,8 @@ import type { - AnimationBindingInstance, - AnimationBindingKind, AnimationChannel, + ChannelData, AnimationInterpolation, AnimationPath, - AnimationValue, DiscreteAnimationChannel, DiscreteAnimationKey, ElementAnimations, @@ -13,13 +11,18 @@ import type { ScalarCurveKeyframePatch, ScalarSegmentType, } from "@/animation/types"; +import type { + ChannelComponentDefinition, + ParamChannelLayout, + ParamValue, +} from "@/params"; import { - cloneAnimationBinding, - createAnimationBinding, - decomposeAnimationValue, -} from "./binding-values"; + getChannelsFromData, + isCompositeChannelData, + isAnimationStorageKey, + isLeafChannelData, +} from "./channel-data"; import { - getBezierPoint, getDefaultLeftHandle, getDefaultRightHandle, solveBezierProgressForTime, @@ -27,8 +30,17 @@ import { import { getChannelValueAtTime, getScalarSegmentInterpolation, + isScalarChannel, normalizeChannel, + normalizeDiscreteChannel, + normalizeScalarChannel, } from "./interpolation"; +import { + type MediaTime, + roundMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { generateUUID } from "@/utils/id"; function isNearlySameTime({ @@ -49,27 +61,27 @@ function hasChannelKeys({ return Boolean(channel && channel.keys.length > 0); } +function hasChannelData({ data }: { data: ChannelData | undefined }): boolean { + return getChannelsFromData({ data }).some((channel) => + hasChannelKeys({ channel }), + ); +} + function toAnimation({ animations, }: { animations: ElementAnimations; }): ElementAnimations | undefined { - const nextBindings = Object.fromEntries( - Object.entries(animations.bindings).filter(([, binding]) => binding), - ); - const nextChannels = Object.fromEntries( - Object.entries(animations.channels).filter(([, channel]) => - hasChannelKeys({ channel }), + const nextAnimations = Object.fromEntries( + Object.entries(animations).filter( + ([key, data]) => isAnimationStorageKey({ key }) && hasChannelData({ data }), ), ); - if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) { + if (Object.keys(nextAnimations).length === 0) { return undefined; } - return { - bindings: nextBindings, - channels: nextChannels, - }; + return nextAnimations; } function cloneAnimationsState({ @@ -77,34 +89,88 @@ function cloneAnimationsState({ }: { animations: ElementAnimations | undefined; }): ElementAnimations { - return { - bindings: { ...(animations?.bindings ?? {}) }, - channels: { ...(animations?.channels ?? {}) }, - }; + return { ...(animations ?? {}) }; } -function getBindingChannelKind({ - kind, +function getChannelFromData({ + data, + componentKey, }: { - kind: AnimationBindingKind; -}): AnimationChannel["kind"] { - return kind === "discrete" ? "discrete" : "scalar"; + data: ChannelData | undefined; + componentKey: string; +}): AnimationChannel | undefined { + if (isLeafChannelData(data)) { + return componentKey === "value" ? data : undefined; + } + if (isCompositeChannelData(data)) { + return data[componentKey]; + } + return undefined; } -function getPrimaryComponent({ - binding, +type LayoutComponent = ChannelComponentDefinition<string>; + +function getLayoutComponents({ + channelLayout, }: { - binding: AnimationBindingInstance; -}) { - return binding.components[0] ?? null; + channelLayout: ParamChannelLayout; +}): LayoutComponent[] { + return channelLayout.kind === "leaf" + ? [channelLayout.component] + : channelLayout.components; } -function getPrimaryChannelId({ - binding, +function getPrimaryComponentKey({ + channelLayout, }: { - binding: AnimationBindingInstance; -}) { - return getPrimaryComponent({ binding })?.channelId ?? null; + channelLayout: ParamChannelLayout; +}): string { + return getLayoutComponents({ channelLayout })[0]?.key ?? "value"; +} + +function getPrimaryChannelFromData({ + data, + channelLayout, +}: { + data: ChannelData | undefined; + channelLayout: ParamChannelLayout; +}): AnimationChannel | undefined { + return getChannelFromData({ + data, + componentKey: getPrimaryComponentKey({ channelLayout }), + }); +} + +function setChannelInData({ + data, + componentKey, + channel, +}: { + data: ChannelData | undefined; + componentKey: string; + channel: AnimationChannel | undefined; +}): ChannelData | undefined { + if (componentKey === "value") { + return channel; + } + const components = isCompositeChannelData(data) ? { ...data } : {}; + if (channel && hasChannelKeys({ channel })) { + components[componentKey] = channel; + } else { + delete components[componentKey]; + } + return Object.keys(components).length > 0 ? components : undefined; +} + +function getChannelDataEntries({ + data, +}: { + data: ChannelData | undefined; +}): Array<[string, AnimationChannel | undefined]> { + if (isLeafChannelData(data)) { + return [["value", data]]; + } + return isCompositeChannelData(data) ? Object.entries(data) : []; } function getScalarSegmentType({ @@ -118,14 +184,14 @@ function getScalarSegmentType({ return interpolation === "bezier" ? "bezier" : "linear"; } -function getInterpolationForBinding({ - kind, +function getInterpolationForComponent({ + component, interpolation, }: { - kind: AnimationBindingKind; + component: LayoutComponent; interpolation: AnimationInterpolation | undefined; }): AnimationInterpolation { - if (kind === "discrete") { + if (component.valueKind === "discrete") { return "hold"; } @@ -137,25 +203,25 @@ function getInterpolationForBinding({ return interpolation; } - return "linear"; + return component.defaultInterpolation; } -function createEmptyChannelForBindingKind({ - kind, +function decomposeChannelLayoutValue({ + channelLayout, + value, }: { - kind: AnimationBindingKind; -}): AnimationChannel { - if (kind === "discrete") { - return { - kind: "discrete", - keys: [], - } satisfies DiscreteAnimationChannel; + channelLayout: ParamChannelLayout; + value: ParamValue; +}): Record<string, ParamValue> | null { + if (channelLayout.kind === "leaf") { + return { [channelLayout.component.key]: value }; + } + if (typeof value !== "string") { + return null; } - return { - kind: "scalar", - keys: [], - } satisfies ScalarAnimationChannel; + const components = channelLayout.decompose(value); + return components ? { ...components } : null; } function createScalarKey({ @@ -166,7 +232,7 @@ function createScalarKey({ previousKey, }: { id: string; - time: number; + time: MediaTime; value: number; interpolation?: AnimationInterpolation; previousKey?: ScalarAnimationKey; @@ -190,7 +256,7 @@ function createDiscreteKey({ value, }: { id: string; - time: number; + time: MediaTime; value: string | boolean; }): DiscreteAnimationKey { return { @@ -200,34 +266,20 @@ function createDiscreteKey({ }; } -function getBinding({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): AnimationBindingInstance | undefined { - return animations?.bindings[propertyPath]; +function isDiscreteChannel( + channel: AnimationChannel | undefined, +): channel is DiscreteAnimationChannel { + return channel != null && !isScalarChannel(channel); } -function getChannelById({ +function getChannelData({ animations, - channelId, + propertyPath, }: { animations: ElementAnimations | undefined; - channelId: string; -}): AnimationChannel | undefined { - return animations?.channels[channelId]; -} - -function getBindingComponent({ - binding, - componentKey, -}: { - binding: AnimationBindingInstance; - componentKey: string; -}) { - return binding.components.find((component) => component.key === componentKey) ?? null; + propertyPath: AnimationPath; +}): ChannelData | undefined { + return animations?.[propertyPath]; } function getTargetKeyMetadata({ @@ -236,7 +288,7 @@ function getTargetKeyMetadata({ keyframeId, }: { channel: AnimationChannel | undefined; - time: number; + time: MediaTime; keyframeId?: string; }) { const normalizedChannel = @@ -275,12 +327,12 @@ function upsertDiscreteChannelKey({ keyframeId, }: { channel: DiscreteAnimationChannel | undefined; - time: number; + time: MediaTime; value: string | boolean; keyframeId?: string; }): DiscreteAnimationChannel { - const normalizedChannel = normalizeChannel({ - channel: channel ?? { kind: "discrete", keys: [] }, + const normalizedChannel = normalizeDiscreteChannel({ + channel: channel ?? { keys: [] }, }); const keys = [...normalizedChannel.keys]; if (keyframeId) { @@ -291,8 +343,8 @@ function upsertDiscreteChannelKey({ time, value, }); - return normalizeChannel({ - channel: { kind: "discrete", keys }, + return normalizeDiscreteChannel({ + channel: { keys }, }); } } @@ -306,8 +358,8 @@ function upsertDiscreteChannelKey({ time: keys[existingAtTimeIndex].time, value, }); - return normalizeChannel({ - channel: { kind: "discrete", keys }, + return normalizeDiscreteChannel({ + channel: { keys }, }); } @@ -318,8 +370,8 @@ function upsertDiscreteChannelKey({ value, }), ); - return normalizeChannel({ - channel: { kind: "discrete", keys }, + return normalizeDiscreteChannel({ + channel: { keys }, }); } @@ -332,14 +384,14 @@ function upsertScalarChannelKey({ keyframeId, }: { channel: ScalarAnimationChannel | undefined; - time: number; + time: MediaTime; value: number; interpolation?: AnimationInterpolation; defaultInterpolation?: AnimationInterpolation; keyframeId?: string; }): ScalarAnimationChannel { - const normalizedChannel = normalizeChannel({ - channel: channel ?? { kind: "scalar", keys: [] }, + const normalizedChannel = normalizeScalarChannel({ + channel: channel ?? { keys: [] }, }); const keys = [...normalizedChannel.keys]; if (keyframeId) { @@ -358,9 +410,8 @@ function upsertScalarChannelKey({ } : keys[existingIndex], }); - return normalizeChannel({ + return normalizeScalarChannel({ channel: { - kind: "scalar", keys, extrapolation: normalizedChannel.extrapolation, }, @@ -385,9 +436,8 @@ function upsertScalarChannelKey({ } : keys[existingAtTimeIndex], }); - return normalizeChannel({ + return normalizeScalarChannel({ channel: { - kind: "scalar", keys, extrapolation: normalizedChannel.extrapolation, }, @@ -402,9 +452,8 @@ function upsertScalarChannelKey({ interpolation: interpolation ?? defaultInterpolation, }), ); - return normalizeChannel({ + return normalizeScalarChannel({ channel: { - kind: "scalar", keys, extrapolation: normalizedChannel.extrapolation, }, @@ -418,10 +467,11 @@ export function getChannel({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; }): AnimationChannel | undefined { - const binding = getBinding({ animations, propertyPath }); - const primaryChannelId = - binding != null ? getPrimaryChannelId({ binding }) : null; - return primaryChannelId ? animations?.channels[primaryChannelId] : undefined; + const data = getChannelData({ animations, propertyPath }); + if (isLeafChannelData(data)) { + return data; + } + return getChannelsFromData({ data })[0]; } export function upsertPathKeyframe({ @@ -431,19 +481,17 @@ export function upsertPathKeyframe({ value, interpolation, keyframeId, - kind, - defaultInterpolation, + channelLayout, coerceValue, }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + channelLayout: ParamChannelLayout; + coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null; }): ElementAnimations | undefined { const coercedValue = coerceValue({ value }); if (coercedValue === null) { @@ -451,71 +499,78 @@ export function upsertPathKeyframe({ } const nextAnimations = cloneAnimationsState({ animations }); - const existingBinding = getBinding({ - animations, - propertyPath, - }); - const binding = - existingBinding && existingBinding.kind === kind - ? cloneAnimationBinding({ binding: existingBinding }) - : createAnimationBinding({ path: propertyPath, kind }); - const primaryChannel = getChannel({ - animations, - propertyPath, + const currentData = getChannelData({ animations, propertyPath }); + const primaryChannel = getPrimaryChannelFromData({ + data: currentData, + channelLayout, }); const targetKey = getTargetKeyMetadata({ channel: primaryChannel, time, keyframeId, }); - const componentValues = decomposeAnimationValue({ - kind, + const componentValues = decomposeChannelLayoutValue({ + channelLayout, value: coercedValue, }); if (!componentValues) { return animations; } - const explicitInterpolation = - interpolation != null - ? getInterpolationForBinding({ kind, interpolation }) - : undefined; - const validatedDefaultInterpolation = getInterpolationForBinding({ - kind, - interpolation: defaultInterpolation, - }); - nextAnimations.bindings[propertyPath] = binding; - for (const component of binding.components) { - const nextValue = componentValues[component.key]; + let nextData: ChannelData | undefined = currentData; + for (const component of getLayoutComponents({ channelLayout })) { + const componentKey = component.key; + const nextValue = componentValues[componentKey]; if (nextValue == null) { continue; } - const currentChannel = getChannelById({ - animations, - channelId: component.channelId, + const currentChannel = getChannelFromData({ + data: currentData, + componentKey, + }); + if (component.valueKind === "discrete") { + if (typeof nextValue !== "string" && typeof nextValue !== "boolean") { + continue; + } + const nextChannel = upsertDiscreteChannelKey({ + channel: isDiscreteChannel(currentChannel) ? currentChannel : undefined, + time: targetKey.time, + value: nextValue, + keyframeId: targetKey.id, + }); + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: nextChannel, + }); + continue; + } + + if (typeof nextValue !== "number") { + continue; + } + const nextChannel = upsertScalarChannelKey({ + channel: + currentChannel != null && isScalarChannel(currentChannel) + ? currentChannel + : undefined, + time: targetKey.time, + value: nextValue, + interpolation: + interpolation != null + ? getInterpolationForComponent({ component, interpolation }) + : undefined, + defaultInterpolation: component.defaultInterpolation, + keyframeId: targetKey.id, + }); + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: nextChannel, }); - const targetChannel = - currentChannel?.kind === getBindingChannelKind({ kind }) - ? currentChannel - : createEmptyChannelForBindingKind({ kind }); - nextAnimations.channels[component.channelId] = - targetChannel.kind === "discrete" - ? upsertDiscreteChannelKey({ - channel: targetChannel, - time: targetKey.time, - value: nextValue as string | boolean, - keyframeId: targetKey.id, - }) - : upsertScalarChannelKey({ - channel: targetChannel, - time: targetKey.time, - value: nextValue as number, - interpolation: explicitInterpolation, - defaultInterpolation: validatedDefaultInterpolation, - keyframeId: targetKey.id, - }); } + nextAnimations[propertyPath] = nextData; return toAnimation({ animations: nextAnimations, @@ -530,8 +585,8 @@ export function upsertKeyframe({ keyframeId, }: { channel: AnimationChannel | undefined; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; }): AnimationChannel | undefined { @@ -539,13 +594,9 @@ export function upsertKeyframe({ return undefined; } - if (channel.kind === "discrete") { - if (typeof value !== "string" && typeof value !== "boolean") { - return channel; - } - + if (typeof value === "string" || typeof value === "boolean") { return upsertDiscreteChannelKey({ - channel, + channel: isDiscreteChannel(channel) ? channel : undefined, time, value, keyframeId, @@ -557,7 +608,7 @@ export function upsertKeyframe({ } return upsertScalarChannelKey({ - channel, + channel: isScalarChannel(channel) ? channel : undefined, time, value, interpolation, @@ -576,16 +627,30 @@ export function removeKeyframe({ return undefined; } + if (isScalarChannel(channel)) { + const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); + if (nextKeys.length === 0) { + return undefined; + } + + return normalizeScalarChannel({ + channel: { + ...channel, + keys: nextKeys, + }, + }); + } + const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); if (nextKeys.length === 0) { return undefined; } - return normalizeChannel({ + return normalizeDiscreteChannel({ channel: { ...channel, keys: nextKeys, - } as AnimationChannel, + }, }); } @@ -596,12 +661,34 @@ export function retimeKeyframe({ }: { channel: AnimationChannel | undefined; keyframeId: string; - time: number; + time: MediaTime; }): AnimationChannel | undefined { if (!channel) { return undefined; } + if (isScalarChannel(channel)) { + const keyframeByIdIndex = channel.keys.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeByIdIndex < 0) { + return channel; + } + + const nextKeys = [...channel.keys]; + nextKeys[keyframeByIdIndex] = { + ...nextKeys[keyframeByIdIndex], + time, + }; + + return normalizeScalarChannel({ + channel: { + ...channel, + keys: nextKeys, + }, + }); + } + const keyframeByIdIndex = channel.keys.findIndex( (keyframe) => keyframe.id === keyframeId, ); @@ -615,11 +702,11 @@ export function retimeKeyframe({ time, }; - return normalizeChannel({ + return normalizeDiscreteChannel({ channel: { ...channel, keys: nextKeys, - } as AnimationChannel, + }, }); } @@ -632,26 +719,10 @@ export function setChannel({ propertyPath: AnimationPath; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - if (binding.components.length !== 1) { - throw new Error( - `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, - ); - } - - const primaryComponent = getPrimaryComponent({ binding }); - if (!primaryComponent) { - return animations; - } - return setBindingComponentChannel({ animations, propertyPath, - componentKey: primaryComponent.key, + componentKey: "value", channel, }); } @@ -667,37 +738,14 @@ export function setBindingComponentChannel({ componentKey: string; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const component = getBindingComponent({ - binding, - componentKey, - }); - if (!component) { - return animations; - } - const nextAnimations = cloneAnimationsState({ animations }); - if (!channel || !hasChannelKeys({ channel })) { - delete nextAnimations.channels[component.channelId]; - const hasRemainingKeys = binding.components.some((candidate) => - hasChannelKeys({ - channel: nextAnimations.channels[candidate.channelId], - }), - ); - if (!hasRemainingKeys) { - delete nextAnimations.bindings[propertyPath]; - } - return toAnimation({ - animations: nextAnimations, - }); - } - - nextAnimations.channels[component.channelId] = normalizeChannel({ - channel, + nextAnimations[propertyPath] = setChannelInData({ + data: nextAnimations[propertyPath], + componentKey, + channel: + channel && hasChannelKeys({ channel }) + ? normalizeChannel({ channel }) + : undefined, }); return toAnimation({ animations: nextAnimations, @@ -717,24 +765,11 @@ export function updateScalarKeyframeCurve({ keyframeId: string; patch: ScalarCurveKeyframePatch; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { - return animations; - } - - const component = getBindingComponent({ - binding, + const channel = getChannelFromData({ + data: getChannelData({ animations, propertyPath }), componentKey, }); - if (!component) { - return animations; - } - - const channel = getChannelById({ - animations, - channelId: component.channelId, - }); - if (channel?.kind !== "scalar") { + if (!channel || !isScalarChannel(channel)) { return animations; } @@ -764,13 +799,40 @@ export function updateScalarKeyframeCurve({ propertyPath, componentKey, channel: { - kind: "scalar", keys: nextKeys, extrapolation: channel.extrapolation, }, }); } +function cloneChannelWithKeyIds({ + channel, + keyIdMap, +}: { + channel: AnimationChannel; + keyIdMap: Map<string, string>; +}): AnimationChannel { + return isScalarChannel(channel) + ? normalizeScalarChannel({ + channel: { + ...channel, + keys: channel.keys.map((key) => ({ + ...key, + id: keyIdMap.get(key.id) ?? key.id, + })), + }, + }) + : normalizeDiscreteChannel({ + channel: { + ...channel, + keys: channel.keys.map((key) => ({ + ...key, + id: keyIdMap.get(key.id) ?? key.id, + })), + }, + }); +} + export function cloneAnimations({ animations, shouldRegenerateKeyframeIds = false, @@ -783,23 +845,11 @@ export function cloneAnimations({ } const nextAnimations = cloneAnimationsState({ animations }); - nextAnimations.bindings = Object.fromEntries( - Object.entries(animations.bindings).map(([path, binding]) => [ - path, - binding ? cloneAnimationBinding({ binding }) : binding, - ]), - ); - nextAnimations.channels = {}; - - for (const binding of Object.values(nextAnimations.bindings)) { - if (!binding) { - continue; - } - - const primaryChannel = getChannelById({ - animations, - channelId: getPrimaryChannelId({ binding }) ?? "", - }); + for (const [propertyPath, data] of Object.entries(animations).filter(([key]) => + isAnimationStorageKey({ key }), + )) { + const channels = getChannelsFromData({ data }); + const primaryChannel = channels[0]; const keyIdMap = new Map<string, string>(); if (primaryChannel) { for (const key of primaryChannel.keys) { @@ -810,24 +860,22 @@ export function cloneAnimations({ } } - for (const component of binding.components) { - const currentChannel = getChannelById({ - animations, - channelId: component.channelId, - }); - if (!currentChannel) { - continue; - } - - nextAnimations.channels[component.channelId] = normalizeChannel({ - channel: { - ...currentChannel, - keys: currentChannel.keys.map((key) => ({ - ...key, - id: keyIdMap.get(key.id) ?? key.id, - })), - } as AnimationChannel, + if (isLeafChannelData(data)) { + nextAnimations[propertyPath] = cloneChannelWithKeyIds({ + channel: data, + keyIdMap, }); + continue; + } + if (isCompositeChannelData(data)) { + nextAnimations[propertyPath] = Object.fromEntries( + Object.entries(data).map(([componentKey, channel]) => [ + componentKey, + channel + ? cloneChannelWithKeyIds({ channel, keyIdMap }) + : undefined, + ]), + ); } } @@ -841,7 +889,7 @@ export function clampAnimationsToDuration({ duration, }: { animations: ElementAnimations | undefined; - duration: number; + duration: MediaTime; }): ElementAnimations | undefined { if (!animations || duration <= 0) { return undefined; @@ -877,7 +925,7 @@ function splitDiscreteChannelAtTime({ shouldIncludeSplitBoundary, }: { channel: DiscreteAnimationChannel | undefined; - splitTime: number; + splitTime: MediaTime; leftBoundaryId: string; rightBoundaryId: string; shouldIncludeSplitBoundary: boolean; @@ -893,7 +941,10 @@ function splitDiscreteChannelAtTime({ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let rightKeys = normalizedChannel.keys .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); + .map((key) => ({ + ...key, + time: subMediaTime({ a: key.time, b: splitTime }), + })); if (shouldIncludeSplitBoundary) { const hasBoundaryOnLeft = leftKeys.some((key) => @@ -913,7 +964,7 @@ function splitDiscreteChannelAtTime({ createDiscreteKey({ id: leftBoundaryId, time: splitTime, - value: boundaryValue as string | boolean, + value: boundaryValue, }), ]; } @@ -921,8 +972,8 @@ function splitDiscreteChannelAtTime({ rightKeys = [ createDiscreteKey({ id: rightBoundaryId, - time: 0, - value: boundaryValue as string | boolean, + time: ZERO_MEDIA_TIME, + value: boundaryValue, }), ...rightKeys, ]; @@ -931,10 +982,10 @@ function splitDiscreteChannelAtTime({ return { leftChannel: leftKeys.length - ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } }) + ? normalizeChannel({ channel: { keys: leftKeys } }) : undefined, rightChannel: rightKeys.length - ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } }) + ? normalizeChannel({ channel: { keys: rightKeys } }) : undefined, }; } @@ -947,7 +998,7 @@ function splitScalarChannelAtTime({ shouldIncludeSplitBoundary, }: { channel: ScalarAnimationChannel | undefined; - splitTime: number; + splitTime: MediaTime; leftBoundaryId: string; rightBoundaryId: string; shouldIncludeSplitBoundary: boolean; @@ -963,7 +1014,10 @@ function splitScalarChannelAtTime({ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let rightKeys = normalizedChannel.keys .filter((key) => key.time >= splitTime) - .map((key) => ({ ...key, time: key.time - splitTime })); + .map((key) => ({ + ...key, + time: subMediaTime({ a: key.time, b: splitTime }), + })); const hasBoundaryOnLeft = leftKeys.some((key) => isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), @@ -976,7 +1030,6 @@ function splitScalarChannelAtTime({ leftChannel: leftKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: leftKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -985,7 +1038,6 @@ function splitScalarChannelAtTime({ rightChannel: rightKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: rightKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1010,7 +1062,7 @@ function splitScalarChannelAtTime({ channel: normalizedChannel, time: splitTime, fallbackValue: leftKey.value, - }) as number; + }); if (leftKey.segmentToNext === "bezier") { const rightHandle = @@ -1043,7 +1095,7 @@ function splitScalarChannelAtTime({ { ...leftKey, rightHandle: { - dt: q0.x - p0.x, + dt: roundMediaTime({ time: q0.x - p0.x }), dv: q0.y - p0.y, }, }, @@ -1052,7 +1104,7 @@ function splitScalarChannelAtTime({ time: splitTime, value: boundaryValue, leftHandle: { - dt: r0.x - splitPoint.x, + dt: roundMediaTime({ time: r0.x - splitPoint.x }), dv: r0.y - splitPoint.y, }, segmentToNext: leftKey.segmentToNext, @@ -1062,10 +1114,10 @@ function splitScalarChannelAtTime({ rightKeys = [ { id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue, rightHandle: { - dt: r1.x - splitPoint.x, + dt: roundMediaTime({ time: r1.x - splitPoint.x }), dv: r1.y - splitPoint.y, }, segmentToNext: "bezier", @@ -1073,9 +1125,9 @@ function splitScalarChannelAtTime({ }, { ...rightKey, - time: rightKey.time - splitTime, + time: subMediaTime({ a: rightKey.time, b: splitTime }), leftHandle: { - dt: q2.x - p3.x, + dt: roundMediaTime({ time: q2.x - p3.x }), dv: q2.y - p3.y, }, }, @@ -1083,7 +1135,7 @@ function splitScalarChannelAtTime({ .filter((key) => key.time > rightKey.time) .map((key) => ({ ...key, - time: key.time - splitTime, + time: subMediaTime({ a: key.time, b: splitTime }), })), ]; } else { @@ -1099,7 +1151,7 @@ function splitScalarChannelAtTime({ rightKeys = [ createScalarKey({ id: rightBoundaryId, - time: 0, + time: ZERO_MEDIA_TIME, value: boundaryValue, interpolation: getScalarSegmentInterpolation({ segment: leftKey.segmentToNext, @@ -1112,14 +1164,12 @@ function splitScalarChannelAtTime({ return { leftChannel: normalizeChannel({ channel: { - kind: "scalar", keys: leftKeys, extrapolation: normalizedChannel.extrapolation, }, }), rightChannel: normalizeChannel({ channel: { - kind: "scalar", keys: rightKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1131,7 +1181,6 @@ function splitScalarChannelAtTime({ leftChannel: leftKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: leftKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1140,7 +1189,6 @@ function splitScalarChannelAtTime({ rightChannel: rightKeys.length ? normalizeChannel({ channel: { - kind: "scalar", keys: rightKeys, extrapolation: normalizedChannel.extrapolation, }, @@ -1149,13 +1197,44 @@ function splitScalarChannelAtTime({ }; } +function splitChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: AnimationChannel | undefined; + splitTime: MediaTime; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + return channel != null && !isScalarChannel(channel) + ? splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }) + : splitScalarChannelAtTime({ + channel: + channel != null && isScalarChannel(channel) ? channel : undefined, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }); +} + export function splitAnimationsAtTime({ animations, splitTime, shouldIncludeSplitBoundary = true, }: { animations: ElementAnimations | undefined; - splitTime: number; + splitTime: MediaTime; shouldIncludeSplitBoundary?: boolean; }): { leftAnimations: ElementAnimations | undefined; @@ -1168,55 +1247,39 @@ export function splitAnimationsAtTime({ const leftAnimations = cloneAnimationsState({ animations: undefined }); const rightAnimations = cloneAnimationsState({ animations: undefined }); - for (const [propertyPath, binding] of Object.entries(animations.bindings)) { - if (!binding) { + for (const [propertyPath, data] of Object.entries(animations).filter(([key]) => + isAnimationStorageKey({ key }), + )) { + if (!data) { continue; } - const leftBinding = cloneAnimationBinding({ binding }); - const rightBinding = cloneAnimationBinding({ binding }); const leftBoundaryId = generateUUID(); const rightBoundaryId = generateUUID(); - let hasLeftKeys = false; - let hasRightKeys = false; - for (const component of binding.components) { - const channel = getChannelById({ - animations, - channelId: component.channelId, + for (const [componentKey, channel] of getChannelDataEntries({ data })) { + const splitResult = splitChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, }); - const splitResult = - channel?.kind === "discrete" - ? splitDiscreteChannelAtTime({ - channel, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, - }) - : splitScalarChannelAtTime({ - channel: channel as ScalarAnimationChannel | undefined, - splitTime, - leftBoundaryId, - rightBoundaryId, - shouldIncludeSplitBoundary, - }); if (splitResult.leftChannel) { - leftAnimations.channels[component.channelId] = splitResult.leftChannel; - hasLeftKeys = true; + leftAnimations[propertyPath] = setChannelInData({ + data: leftAnimations[propertyPath], + componentKey, + channel: splitResult.leftChannel, + }); } if (splitResult.rightChannel) { - rightAnimations.channels[component.channelId] = splitResult.rightChannel; - hasRightKeys = true; + rightAnimations[propertyPath] = setChannelInData({ + data: rightAnimations[propertyPath], + componentKey, + channel: splitResult.rightChannel, + }); } } - - if (hasLeftKeys) { - leftAnimations.bindings[propertyPath] = leftBinding; - } - if (hasRightKeys) { - rightAnimations.bindings[propertyPath] = rightBinding; - } } return { @@ -1234,28 +1297,24 @@ export function removeElementKeyframe({ propertyPath: AnimationPath; keyframeId: string; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { + const data = getChannelData({ animations, propertyPath }); + if (!data) { return animations; } const nextAnimations = cloneAnimationsState({ animations }); - for (const component of binding.components) { - nextAnimations.channels[component.channelId] = removeKeyframe({ - channel: nextAnimations.channels[component.channelId], - keyframeId, - }); - } - const hasRemainingKeys = binding.components.some((component) => - hasChannelKeys({ - channel: nextAnimations.channels[component.channelId], - }), - ); - if (!hasRemainingKeys) { - delete nextAnimations.bindings[propertyPath]; - for (const component of binding.components) { - delete nextAnimations.channels[component.channelId]; + if (isLeafChannelData(data)) { + nextAnimations[propertyPath] = removeKeyframe({ channel: data, keyframeId }); + } else if (isCompositeChannelData(data)) { + let nextData: ChannelData | undefined = data; + for (const [componentKey, channel] of Object.entries(data)) { + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: removeKeyframe({ channel, keyframeId }), + }); } + nextAnimations[propertyPath] = nextData; } return toAnimation({ animations: nextAnimations, @@ -1271,20 +1330,30 @@ export function retimeElementKeyframe({ animations: ElementAnimations | undefined; propertyPath: AnimationPath; keyframeId: string; - time: number; + time: MediaTime; }): ElementAnimations | undefined { - const binding = getBinding({ animations, propertyPath }); - if (!binding) { + const data = getChannelData({ animations, propertyPath }); + if (!data) { return animations; } const nextAnimations = cloneAnimationsState({ animations }); - for (const component of binding.components) { - nextAnimations.channels[component.channelId] = retimeKeyframe({ - channel: nextAnimations.channels[component.channelId], + if (isLeafChannelData(data)) { + nextAnimations[propertyPath] = retimeKeyframe({ + channel: data, keyframeId, time, }); + } else if (isCompositeChannelData(data)) { + let nextData: ChannelData | undefined = data; + for (const [componentKey, channel] of Object.entries(data)) { + nextData = setChannelInData({ + data: nextData, + componentKey, + channel: retimeKeyframe({ channel, keyframeId, time }), + }); + } + nextAnimations[propertyPath] = nextData; } return toAnimation({ animations: nextAnimations, diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts index 19685cea..331dcbaa 100644 --- a/apps/web/src/animation/resolve.ts +++ b/apps/web/src/animation/resolve.ts @@ -1,15 +1,13 @@ import type { AnimationPath, - AnimationValueForPath, ElementAnimations, } from "@/animation/types"; -import { - type AnimationComponentValue, - composeAnimationValue, - decomposeAnimationValue, -} from "./binding-values"; +import { formatLinearRgba, parseColorToLinearRgba } from "@/params"; +import type { ParamValue } from "@/params"; +import { isCompositeChannelData, isLeafChannelData } from "./channel-data"; import { getChannelValueAtTime, + isScalarChannel, } from "./interpolation"; export function getElementLocalTime({ @@ -33,47 +31,123 @@ export function getElementLocalTime({ return localTime; } -export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({ +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: number; +}): number; +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: string; +}): string; +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: boolean; +}): boolean; +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + localTime: number; + fallbackValue: ParamValue; +}): ParamValue; +export function resolveAnimationPathValueAtTime({ animations, propertyPath, localTime, fallbackValue, }: { animations: ElementAnimations | undefined; - propertyPath: TPath; + propertyPath: AnimationPath; localTime: number; - fallbackValue: AnimationValueForPath<TPath>; -}): AnimationValueForPath<TPath> { - const binding = animations?.bindings[propertyPath]; - if (!binding) { + fallbackValue: ParamValue; +}): ParamValue { + const data = animations?.[propertyPath]; + if (!data) { + return fallbackValue; + } + if (isLeafChannelData(data)) { + if (typeof fallbackValue === "number") { + return getChannelValueAtTime({ + channel: isScalarChannel(data) ? data : undefined, + time: localTime, + fallbackValue, + }); + } + if (typeof fallbackValue === "string" || typeof fallbackValue === "boolean") { + return getChannelValueAtTime({ + channel: !isScalarChannel(data) ? data : undefined, + time: localTime, + fallbackValue, + }); + } + return fallbackValue; + } + if (!isCompositeChannelData(data)) { return fallbackValue; } - const fallbackComponents = decomposeAnimationValue({ - kind: binding.kind, - value: fallbackValue, - }); - if (!fallbackComponents) { + if ( + typeof fallbackValue !== "string" || + !("r" in data) || + !("g" in data) || + !("b" in data) || + !("a" in data) + ) { return fallbackValue; } - const componentValues = Object.fromEntries( - binding.components.map((component) => { - const channel = animations?.channels[component.channelId]; - return [ - component.key, - getChannelValueAtTime({ - channel, - time: localTime, - fallbackValue: - fallbackComponents[component.key] ?? - (channel?.kind === "discrete" ? false : 0), - }), - ]; - }), - ) as Record<string, AnimationComponentValue | undefined>; - return (composeAnimationValue({ - binding, - componentValues, - }) ?? fallbackValue) as AnimationValueForPath<TPath>; + const fallbackComponents = parseColorToLinearRgba({ color: fallbackValue }); + if (fallbackComponents === null) { + return fallbackValue; + } + + return formatLinearRgba({ + color: { + r: getChannelValueAtTime({ + channel: data.r && isScalarChannel(data.r) ? data.r : undefined, + time: localTime, + fallbackValue: fallbackComponents.r, + }), + g: getChannelValueAtTime({ + channel: data.g && isScalarChannel(data.g) ? data.g : undefined, + time: localTime, + fallbackValue: fallbackComponents.g, + }), + b: getChannelValueAtTime({ + channel: data.b && isScalarChannel(data.b) ? data.b : undefined, + time: localTime, + fallbackValue: fallbackComponents.b, + }), + a: getChannelValueAtTime({ + channel: data.a && isScalarChannel(data.a) ? data.a : undefined, + time: localTime, + fallbackValue: fallbackComponents.a, + }), + }, + }); } diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts index 7e241994..9b5bd0c7 100644 --- a/apps/web/src/animation/types.ts +++ b/apps/web/src/animation/types.ts @@ -1,3 +1,6 @@ +import type { MediaTime } from "@/wasm"; +import type { ParamValue } from "@/params"; + export const ANIMATION_PROPERTY_PATHS = [ "transform.positionX", "transform.positionY", @@ -18,10 +21,7 @@ export const ANIMATION_PROPERTY_PATHS = [ export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; export type GraphicParamPath = `params.${string}`; export type EffectParamPath = `effects.${string}.params.${string}`; -export type AnimationPath = - | AnimationPropertyPath - | GraphicParamPath - | EffectParamPath; +export type AnimationPath = string; export const ANIMATION_PROPERTY_GROUPS = { "transform.scale": ["transform.scaleX", "transform.scaleY"], @@ -29,44 +29,21 @@ export const ANIMATION_PROPERTY_GROUPS = { export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; -export type VectorValue = { x: number; y: number }; export type DiscreteValue = boolean | string; -export type AnimationValue = number | string | boolean | VectorValue; -export interface AnimationPropertyValueMap { - "transform.positionX": number; - "transform.positionY": number; - "transform.scaleX": number; - "transform.scaleY": number; - "transform.rotate": number; - opacity: number; - volume: number; - color: string; - "background.color": string; - "background.paddingX": number; - "background.paddingY": number; - "background.offsetX": number; - "background.offsetY": number; - "background.cornerRadius": number; -} -export type DynamicAnimationPathValue = number | string | boolean; export interface NumericSpec { min?: number; max?: number; step?: number; } -export type AnimationValueForPath<TPath extends AnimationPath> = - TPath extends AnimationPropertyPath - ? AnimationPropertyValueMap[TPath] - : TPath extends GraphicParamPath | EffectParamPath - ? DynamicAnimationPathValue - : never; -export type AnimationNumericPropertyPath = { - [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never; -}[AnimationPropertyPath]; -export type AnimationColorPropertyPath = { - [K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never; -}[AnimationPropertyPath]; +export type AnimationColorPropertyPath = Extract< + AnimationPropertyPath, + "color" | "background.color" +>; +export type AnimationNumericPropertyPath = Exclude< + AnimationPropertyPath, + AnimationColorPropertyPath +>; export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; export type DiscreteKeyframeInterpolation = "hold"; @@ -74,20 +51,18 @@ export type AnimationInterpolation = | ContinuousKeyframeInterpolation | DiscreteKeyframeInterpolation; -export type PrimitiveAnimationChannelKind = "scalar" | "discrete"; -export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete"; export type ScalarSegmentType = "step" | "linear" | "bezier"; export type TangentMode = "auto" | "aligned" | "broken" | "flat"; export type ChannelExtrapolationMode = "hold" | "linear"; export interface CurveHandle { - dt: number; + dt: MediaTime; dv: number; } -interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> { +interface BaseAnimationKeyframe<TValue extends ParamValue> { id: string; - time: number; // relative to element start time + time: MediaTime; // relative to element start time value: TValue; } @@ -98,13 +73,16 @@ export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> { tangentMode: TangentMode; } -export interface DiscreteAnimationKey - extends BaseAnimationKeyframe<DiscreteValue> {} +export type DiscreteAnimationKey = BaseAnimationKeyframe<DiscreteValue>; -export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey; +export type Keyframe<TValue extends ParamValue = ParamValue> = + TValue extends number + ? ScalarAnimationKey + : TValue extends DiscreteValue + ? DiscreteAnimationKey + : never; -export interface ScalarAnimationChannel { - kind: "scalar"; +export interface ScalarChannel { keys: ScalarAnimationKey[]; extrapolation?: { before: ChannelExtrapolationMode; @@ -112,72 +90,26 @@ export interface ScalarAnimationChannel { }; } -export interface DiscreteAnimationChannel { - kind: "discrete"; +export interface DiscreteChannel { keys: DiscreteAnimationKey[]; } -export type AnimationChannel = - | ScalarAnimationChannel - | DiscreteAnimationChannel; - -export type ElementAnimationChannelMap = Record< - string, - AnimationChannel | undefined ->; - -export interface AnimationBindingComponent<TKey extends string = string> { - key: TKey; - channelId: string; -} - -interface BaseAnimationBinding< - TKind extends AnimationBindingKind, - TComponentKey extends string, -> { - path: AnimationPath; - kind: TKind; - components: AnimationBindingComponent<TComponentKey>[]; -} - -export interface NumberAnimationBinding - extends BaseAnimationBinding<"number", "value"> {} - -export interface Vector2AnimationBinding - extends BaseAnimationBinding<"vector2", "x" | "y"> {} - -export interface ColorAnimationBinding - extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> { - colorSpace: "srgb-linear"; -} - -export interface DiscreteAnimationBinding - extends BaseAnimationBinding<"discrete", "value"> {} - -export type AnimationBindingInstance = - | NumberAnimationBinding - | Vector2AnimationBinding - | ColorAnimationBinding - | DiscreteAnimationBinding; - -export interface AnimationBindingByKind { - number: NumberAnimationBinding; - vector2: Vector2AnimationBinding; - color: ColorAnimationBinding; - discrete: DiscreteAnimationBinding; -} +export type Channel<TValue extends ParamValue = ParamValue> = + TValue extends number + ? ScalarChannel + : TValue extends DiscreteValue + ? DiscreteChannel + : never; -export type AnimationBindingOfKind<TKind extends AnimationBindingKind> = - AnimationBindingByKind[TKind]; +export type ScalarAnimationChannel = Channel<number>; +export type DiscreteAnimationChannel = Channel<DiscreteValue>; +export type AnimationChannel = Channel; -export type ElementAnimationBindingMap = Record< - string, - AnimationBindingInstance | undefined ->; +export type CompositeChannelData = Record<string, AnimationChannel | undefined>; +export type ChannelData = AnimationChannel | CompositeChannelData; export interface ElementAnimations { - bindings: ElementAnimationBindingMap; - channels: ElementAnimationChannelMap; + [propertyPath: AnimationPath]: ChannelData | undefined; } export type NormalizedCubicBezier = [number, number, number, number]; @@ -185,7 +117,6 @@ export type NormalizedCubicBezier = [number, number, number, number]; export interface ScalarGraphChannelTarget { propertyPath: AnimationPath; componentKey: string; - channelId: string; } export interface ScalarGraphChannel extends ScalarGraphChannelTarget { @@ -213,8 +144,8 @@ export interface ScalarCurveKeyframePatch { export interface ElementKeyframe { propertyPath: AnimationPath; id: string; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation: AnimationInterpolation; } diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx index 94e3f3e1..9728cbc7 100644 --- a/apps/web/src/app/brand/page.tsx +++ b/apps/web/src/app/brand/page.tsx @@ -190,7 +190,7 @@ export default function BrandPage() { </div> <div className="flex flex-col gap-3"> - <h2 className="font-semibold text-lg">What's not allowed</h2> + <h2 className="font-semibold text-lg">What's not allowed</h2> <ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed"> {[ "Using OpenCut in the name of your product, service, or domain.", diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index b3d40a37..680dfff4 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -130,8 +130,14 @@ function EditorLayout() { direction="vertical" className="size-full gap-[0.18rem]" onLayout={(sizes) => { - setPanel("mainContent", sizes[0] ?? panels.mainContent); - setPanel("timeline", sizes[1] ?? panels.timeline); + setPanel({ + panel: "mainContent", + size: sizes[0] ?? panels.mainContent, + }); + setPanel({ + panel: "timeline", + size: sizes[1] ?? panels.timeline, + }); }} > <ResizablePanel @@ -144,9 +150,12 @@ function EditorLayout() { direction="horizontal" className="size-full gap-[0.19rem] px-3" onLayout={(sizes) => { - setPanel("tools", sizes[0] ?? panels.tools); - setPanel("preview", sizes[1] ?? panels.preview); - setPanel("properties", sizes[2] ?? panels.properties); + setPanel({ panel: "tools", size: sizes[0] ?? panels.tools }); + setPanel({ panel: "preview", size: sizes[1] ?? panels.preview }); + setPanel({ + panel: "properties", + size: sizes[2] ?? panels.properties, + }); }} > <ResizablePanel diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 6362146e..17775147 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -57,7 +57,10 @@ export default function PrivacyPage() { content is tracked </li> <li>You can clear local data from your browser at any time</li> - <li>We don't sell or share your data with anyone (we don't even have it)</li> + <li> + We don't sell or share your data with anyone (we don't + even have it) + </li> </ol> <p className="mt-4"> Questions? Email us at{" "} @@ -172,7 +175,7 @@ export default function PrivacyPage() { <a href={SOCIAL_LINKS.github} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > GitHub @@ -189,7 +192,7 @@ export default function PrivacyPage() { <a href={`${SOCIAL_LINKS.github}/issues`} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > GitHub repository @@ -205,7 +208,7 @@ export default function PrivacyPage() { <a href={SOCIAL_LINKS.x} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > X (Twitter) diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx index 0845892c..5f2e0420 100644 --- a/apps/web/src/app/terms/page.tsx +++ b/apps/web/src/app/terms/page.tsx @@ -51,9 +51,13 @@ export default function TermsPage() { Free for personal and commercial use with no watermarks or restrictions </li> - <li>You're responsible for how you use it - don't break the law</li> <li> - Service provided "as is" - we can't guarantee perfect uptime + You're responsible for how you use it - don't break + the law + </li> + <li> + Service provided "as is" - we can't guarantee + perfect uptime </li> <li> Open source means you can review our code and self-host if @@ -109,8 +113,8 @@ export default function TermsPage() { </li> </ul> <p> - You're responsible for how you use OpenCut and the content you create. - Don't use it for anything illegal in your jurisdiction. + You're responsible for how you use OpenCut and the content you + create. Don't use it for anything illegal in your jurisdiction. </p> </section> @@ -127,8 +131,8 @@ export default function TermsPage() { <h2 className="text-2xl font-semibold">Service</h2> <p> OpenCut does not currently require an account. The service is provided - "as is" without warranties. While we strive for reliability, we can't - guarantee uninterrupted service. + "as is" without warranties. While we strive for + reliability, we can't guarantee uninterrupted service. </p> </section> @@ -146,7 +150,7 @@ export default function TermsPage() { <a href={SOCIAL_LINKS.github} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > GitHub @@ -161,12 +165,12 @@ export default function TermsPage() { OpenCut is provided free of charge. To the extent permitted by law: </p> <ul className="list-disc space-y-2 pl-6"> - <li>We're not liable for any loss of data or content</li> + <li>We're not liable for any loss of data or content</li> <li> Projects are stored in your browser and may be lost if you clear browser data </li> - <li>We're not responsible for how you use the service</li> + <li>We're not responsible for how you use the service</li> <li>Our liability is limited to the maximum extent allowed by law</li> </ul> <p> @@ -180,7 +184,7 @@ export default function TermsPage() { <h2 className="text-2xl font-semibold">Service Changes</h2> <p>We may update OpenCut and these terms:</p> <ul className="list-disc space-y-2 pl-6"> - <li>We'll notify you of significant changes to these terms</li> + <li>We'll notify you of significant changes to these terms</li> <li>Continued use means you accept any updates</li> <li>You can always self-host an older version if you prefer</li> <li>Major changes will be discussed with the community on GitHub</li> @@ -203,7 +207,7 @@ export default function TermsPage() { <a href={`${SOCIAL_LINKS.github}/issues`} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > GitHub repository @@ -219,7 +223,7 @@ export default function TermsPage() { <a href={SOCIAL_LINKS.x} target="_blank" - rel="noopener" + rel="noopener noreferrer" className="text-primary hover:underline" > X (Twitter) diff --git a/apps/web/src/clipboard/handlers/elements.ts b/apps/web/src/clipboard/handlers/elements.ts index b2f5f2c3..2297b493 100644 --- a/apps/web/src/clipboard/handlers/elements.ts +++ b/apps/web/src/clipboard/handlers/elements.ts @@ -35,7 +35,7 @@ export const ElementsClipboardHandler = { }; }, - paste(entry, context) { + paste({ entry, context }) { if (entry.items.length === 0) { return null; } diff --git a/apps/web/src/clipboard/handlers/index.ts b/apps/web/src/clipboard/handlers/index.ts index 29d4d1d0..a042c3dd 100644 --- a/apps/web/src/clipboard/handlers/index.ts +++ b/apps/web/src/clipboard/handlers/index.ts @@ -45,5 +45,5 @@ export function buildPasteClipboardCommand({ const handler = clipboardHandlers[entry.type] as ClipboardHandler< typeof entry.type >; - return handler.paste(entry as never, context); + return handler.paste({ entry: entry as never, context }); } diff --git a/apps/web/src/clipboard/handlers/keyframes.ts b/apps/web/src/clipboard/handlers/keyframes.ts index ad156e12..dde8384f 100644 --- a/apps/web/src/clipboard/handlers/keyframes.ts +++ b/apps/web/src/clipboard/handlers/keyframes.ts @@ -1,4 +1,6 @@ import { getKeyframeById } from "@/animation"; +import { getChannelEntriesFromData } from "@/animation/channel-data"; +import { isScalarChannel } from "@/animation/interpolation"; import type { SelectedKeyframeRef } from "@/animation/types"; import type { TimelineElement } from "@/timeline"; import { PasteKeyframesCommand } from "@/commands/timeline"; @@ -7,6 +9,7 @@ import type { KeyframeClipboardCurvePatch, KeyframeClipboardItem, } from "../types"; +import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm"; function resolveSingleSourceElement({ selectedKeyframes, @@ -40,14 +43,13 @@ function getCurvePatches({ propertyPath: KeyframeClipboardItem["propertyPath"]; keyframeId: string; }): KeyframeClipboardCurvePatch[] { - const binding = element.animations?.bindings[propertyPath]; - if (!binding) { + const data = element.animations?.[propertyPath]; + if (!data) { return []; } - return binding.components.flatMap((component) => { - const channel = element.animations?.channels[component.channelId]; - if (channel?.kind !== "scalar") { + return getChannelEntriesFromData({ data }).flatMap(([componentKey, channel]) => { + if (!channel || !isScalarChannel(channel)) { return []; } @@ -60,7 +62,7 @@ function getCurvePatches({ return [ { - componentKey: component.key, + componentKey, patch: { leftHandle: keyframe.leftHandle ?? null, rightHandle: keyframe.rightHandle ?? null, @@ -80,7 +82,7 @@ function buildClipboardItem({ element: TimelineElement; propertyPath: KeyframeClipboardItem["propertyPath"]; keyframeId: string; -}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null { +}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null { const keyframe = getKeyframeById({ animations: element.animations, propertyPath, @@ -136,10 +138,11 @@ export const KeyframesClipboardHandler = { } const minTime = Math.min(...rawItems.map((item) => item.time)); + const minTimeMedia = roundMediaTime({ time: minTime }); const items = rawItems .map(({ time, ...item }) => ({ ...item, - timeOffset: time - minTime, + timeOffset: subMediaTime({ a: time, b: minTimeMedia }), })) .sort( (left, right) => @@ -154,7 +157,10 @@ export const KeyframesClipboardHandler = { }; }, - paste(entry, { selectedElements, time }) { + paste({ + entry, + context: { selectedElements, time }, + }) { const targetElement = selectedElements[0]; if (!targetElement || entry.items.length === 0) { return null; diff --git a/apps/web/src/clipboard/types.ts b/apps/web/src/clipboard/types.ts index 0d249c9d..24ca9105 100644 --- a/apps/web/src/clipboard/types.ts +++ b/apps/web/src/clipboard/types.ts @@ -2,16 +2,17 @@ import type { EditorCore } from "@/core"; import type { AnimationInterpolation, AnimationPath, - AnimationValue, ScalarCurveKeyframePatch, SelectedKeyframeRef, } from "@/animation/types"; +import type { ParamValue } from "@/params"; import type { Command } from "@/commands/base-command"; import type { CreateTimelineElement, ElementRef, TrackType, } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface ElementClipboardItem { trackId: string; @@ -26,8 +27,8 @@ export interface KeyframeClipboardCurvePatch { export interface KeyframeClipboardItem { propertyPath: AnimationPath; - timeOffset: number; - value: AnimationValue; + timeOffset: MediaTime; + value: ParamValue; interpolation: AnimationInterpolation; curvePatches: KeyframeClipboardCurvePatch[]; } @@ -61,17 +62,17 @@ export interface PasteContext { editor: EditorCore; selectedElements: ElementRef[]; selectedKeyframes: SelectedKeyframeRef[]; - time: number; + time: MediaTime; } export interface ClipboardHandler<TType extends ClipboardEntryType> { type: TType; canCopy(context: CopyContext): boolean; copy(context: CopyContext): ClipboardEntryByType[TType] | null; - paste( - entry: ClipboardEntryByType[TType], - context: PasteContext, - ): Command | null; + paste(args: { + entry: ClipboardEntryByType[TType]; + context: PasteContext; + }): Command | null; } export type ClipboardHandlerMap = { diff --git a/apps/web/src/commands/media/add-media-asset.ts b/apps/web/src/commands/media/add-media-asset.ts index d22241a2..629bc3ee 100644 --- a/apps/web/src/commands/media/add-media-asset.ts +++ b/apps/web/src/commands/media/add-media-asset.ts @@ -16,14 +16,22 @@ export class AddMediaAssetCommand extends Command { private previousProjectFps: FrameRate | null = null; private appliedProjectFps: FrameRate | null = null; - constructor( - private projectId: string, - private asset: Omit<MediaAsset, "id">, - ) { + constructor({ + projectId, + asset, + }: { + projectId: string; + asset: Omit<MediaAsset, "id">; + }) { super(); + this.projectId = projectId; + this.asset = asset; this.assetId = generateUUID(); } + private projectId: string; + private asset: Omit<MediaAsset, "id">; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedAssets = [...editor.media.getAssets()]; @@ -117,7 +125,14 @@ export class AddMediaAssetCommand extends Command { const activeProject = editor.project.getActiveOrNull(); if (!activeProject) return; - if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return; + if ( + !this.appliedProjectFps || + !frameRatesEqual({ + a: activeProject.settings.fps, + b: this.appliedProjectFps, + }) + ) + return; const highestRemainingVideoFps = getHighestImportedVideoFps({ mediaAssets: editor.media.getAssets(), diff --git a/apps/web/src/commands/media/remove-media-asset.ts b/apps/web/src/commands/media/remove-media-asset.ts index 8da62181..38ff79fe 100644 --- a/apps/web/src/commands/media/remove-media-asset.ts +++ b/apps/web/src/commands/media/remove-media-asset.ts @@ -13,13 +13,21 @@ export class RemoveMediaAssetCommand extends Command { private savedTracks: SceneTracks | null = null; private removedAsset: MediaAsset | null = null; - constructor( - private projectId: string, - private assetId: string, - ) { + constructor({ + projectId, + assetId, + }: { + projectId: string; + assetId: string; + }) { super(); + this.projectId = projectId; + this.assetId = assetId; } + private projectId: string; + private assetId: string; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const assets = editor.media.getAssets(); diff --git a/apps/web/src/commands/scene/create-scene.ts b/apps/web/src/commands/scene/create-scene.ts index b554fe90..3776467d 100644 --- a/apps/web/src/commands/scene/create-scene.ts +++ b/apps/web/src/commands/scene/create-scene.ts @@ -7,13 +7,21 @@ export class CreateSceneCommand extends Command { private savedScenes: TScene[] | null = null; private createdScene: TScene | null = null; - constructor( - private name: string, - private isMain: boolean = false, - ) { + constructor({ + name, + isMain = false, + }: { + name: string; + isMain?: boolean; + }) { super(); + this.name = name; + this.isMain = isMain; } + private name: string; + private isMain: boolean; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedScenes = [...editor.scenes.getScenes()]; diff --git a/apps/web/src/commands/scene/move-bookmark.ts b/apps/web/src/commands/scene/move-bookmark.ts index a1624eb0..45b7bda5 100644 --- a/apps/web/src/commands/scene/move-bookmark.ts +++ b/apps/web/src/commands/scene/move-bookmark.ts @@ -3,17 +3,26 @@ import { EditorCore } from "@/core"; import type { TScene } from "@/timeline"; import { updateSceneInArray } from "@/timeline/scenes"; import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class MoveBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - constructor( - private fromTime: number, - private toTime: number, - ) { + constructor({ + fromTime, + toTime, + }: { + fromTime: MediaTime; + toTime: MediaTime; + }) { super(); + this.fromTime = fromTime; + this.toTime = toTime; } + private fromTime: MediaTime; + private toTime: MediaTime; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeScene = editor.scenes.getActiveScene(); diff --git a/apps/web/src/commands/scene/remove-bookmark.ts b/apps/web/src/commands/scene/remove-bookmark.ts index ea592556..95403e0e 100644 --- a/apps/web/src/commands/scene/remove-bookmark.ts +++ b/apps/web/src/commands/scene/remove-bookmark.ts @@ -6,12 +6,13 @@ import { getFrameTime, removeBookmarkFromArray, } from "@/timeline/bookmarks/index"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export class RemoveBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - private frameTime: number = 0; + private frameTime: MediaTime = ZERO_MEDIA_TIME; - constructor(private time: number) { + constructor(private time: MediaTime) { super(); } diff --git a/apps/web/src/commands/scene/rename-scene.ts b/apps/web/src/commands/scene/rename-scene.ts index 19d6bfd8..8bc8724b 100644 --- a/apps/web/src/commands/scene/rename-scene.ts +++ b/apps/web/src/commands/scene/rename-scene.ts @@ -7,13 +7,21 @@ export class RenameSceneCommand extends Command { private savedScenes: TScene[] | null = null; private previousName: string | null = null; - constructor( - private sceneId: string, - private newName: string, - ) { + constructor({ + sceneId, + newName, + }: { + sceneId: string; + newName: string; + }) { super(); + this.sceneId = sceneId; + this.newName = newName; } + private sceneId: string; + private newName: string; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const scenes = editor.scenes.getScenes(); diff --git a/apps/web/src/commands/scene/toggle-bookmark.ts b/apps/web/src/commands/scene/toggle-bookmark.ts index 4193c76e..31751be4 100644 --- a/apps/web/src/commands/scene/toggle-bookmark.ts +++ b/apps/web/src/commands/scene/toggle-bookmark.ts @@ -6,12 +6,13 @@ import { getFrameTime, toggleBookmarkInArray, } from "@/timeline/bookmarks/index"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export class ToggleBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - private frameTime: number = 0; + private frameTime: MediaTime = ZERO_MEDIA_TIME; - constructor(private time: number) { + constructor(private time: MediaTime) { super(); } diff --git a/apps/web/src/commands/scene/update-bookmark.ts b/apps/web/src/commands/scene/update-bookmark.ts index 0d6a0193..7208abc4 100644 --- a/apps/web/src/commands/scene/update-bookmark.ts +++ b/apps/web/src/commands/scene/update-bookmark.ts @@ -6,17 +6,26 @@ import { getFrameTime, updateBookmarkInArray, } from "@/timeline/bookmarks/index"; +import type { MediaTime } from "@/wasm"; export class UpdateBookmarkCommand extends Command { private savedScenes: TScene[] | null = null; - constructor( - private time: number, - private updates: Partial<Omit<Bookmark, "time">>, - ) { + constructor({ + time, + updates, + }: { + time: MediaTime; + updates: Partial<Omit<Bookmark, "time">>; + }) { super(); + this.time = time; + this.updates = updates; } + private time: MediaTime; + private updates: Partial<Omit<Bookmark, "time">>; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeScene = editor.scenes.getActiveScene(); diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts index c19bd526..a587ef14 100644 --- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -10,6 +10,13 @@ import type { SceneTracks, TimelineElement } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; import { generateUUID } from "@/utils/id"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; function pasteKeyframesIntoElement({ element, @@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({ clipboardItems, }: { element: TimelineElement; - time: number; + time: MediaTime; clipboardItems: KeyframeClipboardItem[]; }): TimelineElement { let nextElement = element; @@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({ continue; } - const keyframeTime = Math.max( - 0, - Math.min(time + item.timeOffset, nextElement.duration), - ); + const keyframeTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ + a: addMediaTime({ a: time, b: item.timeOffset }), + b: nextElement.duration, + }), + }); const nextAnimations = upsertPathKeyframe({ animations: nextElement.animations, propertyPath: item.propertyPath, @@ -42,8 +52,7 @@ function pasteKeyframesIntoElement({ value: item.value, interpolation: item.interpolation, keyframeId: generateUUID(), - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, + channelLayout: target.channelLayout, coerceValue: target.coerceValue, }); const pastedKeyframe = getKeyframeAtTime({ @@ -79,7 +88,7 @@ export class PasteKeyframesCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly time: number; + private readonly time: MediaTime; private readonly clipboardItems: KeyframeClipboardItem[]; constructor({ @@ -90,7 +99,7 @@ export class PasteKeyframesCommand extends Command { }: { trackId: string; elementId: string; - time: number; + time: MediaTime; clipboardItems: KeyframeClipboardItem[]; }) { super(); diff --git a/apps/web/src/commands/timeline/clipboard/paste.ts b/apps/web/src/commands/timeline/clipboard/paste.ts index 4523566e..be0c2b91 100644 --- a/apps/web/src/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/commands/timeline/clipboard/paste.ts @@ -13,18 +13,25 @@ import { enforceMainTrackStart, } from "@/timeline/placement"; import { cloneAnimations } from "@/animation"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class PasteCommand extends Command { private savedState: SceneTracks | null = null; private pastedElements: { trackId: string; elementId: string }[] = []; - private readonly time: number; + private readonly time: MediaTime; private readonly clipboardItems: ElementClipboardItem[]; constructor({ time, clipboardItems, }: { - time: number; + time: MediaTime; clipboardItems: ElementClipboardItem[]; }) { super(); @@ -39,8 +46,12 @@ export class PasteCommand extends Command { this.savedState = editor.scenes.getActiveScene().tracks; this.pastedElements = []; - const minStart = Math.min( - ...this.clipboardItems.map((item) => item.element.startTime), + const minStart = this.clipboardItems.reduce( + (earliestStartTime, item) => + item.element.startTime < earliestStartTime + ? item.element.startTime + : earliestStartTime, + this.clipboardItems[0].element.startTime, ); let updatedTracks = this.savedState; @@ -97,12 +108,18 @@ export class PasteCommand extends Command { targetTrackId: targetTrack.id, requestedStartTime: earliestElement.startTime, }); - const delta = adjustedEarliestStartTime - earliestElement.startTime; + const delta = subMediaTime({ + a: adjustedEarliestStartTime, + b: earliestElement.startTime, + }); - if (delta !== 0) { + if (delta !== ZERO_MEDIA_TIME) { elementsForPlacement = elementsToAdd.map((element) => ({ ...element, - startTime: Math.max(0, element.startTime + delta), + startTime: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: element.startTime, b: delta }), + }), })); } } @@ -168,14 +185,20 @@ function buildPastedElements({ time, }: { items: ElementClipboardItem[]; - minStart: number; - time: number; + minStart: MediaTime; + time: MediaTime; }): TimelineElement[] { const elementsToAdd: TimelineElement[] = []; for (const item of items) { - const relativeOffset = item.element.startTime - minStart; - const startTime = Math.max(0, time + relativeOffset); + const relativeOffset = subMediaTime({ + a: item.element.startTime, + b: minStart, + }); + const startTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: time, b: relativeOffset }), + }); const newElementId = generateUUID(); elementsToAdd.push({ diff --git a/apps/web/src/commands/timeline/element/duplicate-elements.ts b/apps/web/src/commands/timeline/element/duplicate-elements.ts index 8f65f7c7..2b1ab4ea 100644 --- a/apps/web/src/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/commands/timeline/element/duplicate-elements.ts @@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement"; import { cloneAnimations } from "@/animation"; +import type { MediaTime } from "@/wasm"; interface DuplicateElementsParams { elements: { trackId: string; elementId: string }[]; @@ -119,7 +120,7 @@ function buildDuplicateElement({ }: { element: TimelineElement; id: string; - startTime: number; + startTime: MediaTime; }): TimelineElement { return { ...element, diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts index 49ac6f2d..dfde0ffe 100644 --- a/apps/web/src/commands/timeline/element/insert-element.ts +++ b/apps/web/src/commands/timeline/element/insert-element.ts @@ -18,6 +18,7 @@ import { resolveTrackPlacement, validateElementTrackCompatibility, } from "@/timeline/placement"; +import { roundMediaTime } from "@/wasm"; type InsertElementPlacement = | { mode: "explicit"; trackId: string } @@ -188,7 +189,7 @@ export class InsertElementCommand extends Command { } } - if (element.type === "text" && !element.content) { + if (element.type === "text" && !element.params.content) { console.error("Text element must have content"); return false; } @@ -266,7 +267,12 @@ export class InsertElementCommand extends Command { placementResult.kind === "existingTrack" ? { ...element, - startTime: placementResult.adjustedStartTime ?? element.startTime, + startTime: + placementResult.adjustedStartTime !== undefined + ? roundMediaTime({ + time: placementResult.adjustedStartTime, + }) + : element.startTime, } : element; diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts index 07abe2be..ad12e680 100644 --- a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts @@ -5,7 +5,8 @@ import { } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; -import type { AnimationPath, AnimationValue } from "@/animation/types"; +import type { AnimationPath } from "@/animation/types"; +import type { ParamValue } from "@/params"; import type { SceneTracks, TimelineElement } from "@/timeline"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; @@ -18,7 +19,7 @@ function removeKeyframeAndPersist({ element: TimelineElement; propertyPath: AnimationPath; keyframeId: string; - valueAtPlayhead: AnimationValue | null; + valueAtPlayhead: ParamValue | null; }): TimelineElement { const target = resolveAnimationTarget({ element, path: propertyPath }); if (!target) { @@ -52,7 +53,7 @@ export class RemoveKeyframeCommand extends Command { private readonly elementId: string; private readonly propertyPath: AnimationPath; private readonly keyframeId: string; - private readonly valueAtPlayhead: AnimationValue | null; + private readonly valueAtPlayhead: ParamValue | null; constructor({ trackId, @@ -65,7 +66,7 @@ export class RemoveKeyframeCommand extends Command { elementId: string; propertyPath: AnimationPath; keyframeId: string; - valueAtPlayhead: AnimationValue | null; + valueAtPlayhead: ParamValue | null; }) { super(); this.trackId = trackId; diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts index aa04e2bc..94054372 100644 --- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts @@ -4,6 +4,12 @@ import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; export class RetimeKeyframeCommand extends Command { @@ -12,7 +18,7 @@ export class RetimeKeyframeCommand extends Command { private readonly elementId: string; private readonly propertyPath: AnimationPath; private readonly keyframeId: string; - private readonly nextTime: number; + private readonly nextTime: MediaTime; constructor({ trackId, @@ -25,7 +31,7 @@ export class RetimeKeyframeCommand extends Command { elementId: string; propertyPath: AnimationPath; keyframeId: string; - nextTime: number; + nextTime: MediaTime; }) { super(); this.trackId = trackId; @@ -48,7 +54,10 @@ export class RetimeKeyframeCommand extends Command { return element; } - const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.nextTime, b: element.duration }), + }); return { ...element, animations: retimeElementKeyframe({ diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index 7bd1b2e5..babe1ad0 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -9,6 +9,12 @@ import { isVisualElement } from "@/timeline/element-utils"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationInterpolation } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class UpsertEffectParamKeyframeCommand extends Command { private savedState: SceneTracks | null = null; @@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { private readonly elementId: string; private readonly effectId: string; private readonly paramKey: string; - private readonly time: number; + private readonly time: MediaTime; private readonly value: number | string | boolean; private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; @@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementId: string; effectId: string; paramKey: string; - time: number; + time: MediaTime; value: number | string | boolean; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementId: this.elementId, elementPredicate: isVisualElement, update: (element) => { - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.time, b: element.duration }), + }); const propertyPath = buildEffectParamPath({ effectId: this.effectId, paramKey: this.paramKey, @@ -81,8 +90,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, + channelLayout: target.channelLayout, coerceValue: target.coerceValue, }); return { ...element, animations }; diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts index 15c18bfc..8ba13a11 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -7,16 +7,22 @@ import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationPath, AnimationInterpolation, - AnimationValue, } from "@/animation/types"; +import type { ParamValue } from "@/params"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class UpsertKeyframeCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly propertyPath: AnimationPath; - private readonly time: number; - private readonly value: AnimationValue; + private readonly time: MediaTime; + private readonly value: ParamValue; private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; @@ -32,8 +38,8 @@ export class UpsertKeyframeCommand extends Command { trackId: string; elementId: string; propertyPath: AnimationPath; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; }) { @@ -64,7 +70,10 @@ export class UpsertKeyframeCommand extends Command { return element; } - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.time, b: element.duration }), + }); return { ...element, animations: upsertPathKeyframe({ @@ -74,8 +83,7 @@ export class UpsertKeyframeCommand extends Command { value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, + channelLayout: target.channelLayout, coerceValue: target.coerceValue, }), }; diff --git a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts index 1445b1f8..fb02554d 100644 --- a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts +++ b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts @@ -1,22 +1,22 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; import { - getCustomMaskClosedStateAfterPointRemoval, - removeCustomMaskPoints, -} from "@/masks/custom-path"; -import type { CustomMask } from "@/masks/types"; + getFreeformPathClosedStateAfterPointRemoval, + removeFreeformPathPoints, +} from "@/masks/freeform/path"; +import type { FreeformPathMask } from "@/masks/types"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline"; -function deletePointsFromCustomMask({ +function deletePointsFromFreeformPathMask({ mask, pointIds, }: { - mask: CustomMask; + mask: FreeformPathMask; pointIds: string[]; -}): CustomMask { +}): FreeformPathMask { const points = mask.params.path; - const nextPoints = removeCustomMaskPoints({ points, pointIds }); + const nextPoints = removeFreeformPathPoints({ points, pointIds }); if (nextPoints.length === points.length) { return mask; } @@ -26,7 +26,7 @@ function deletePointsFromCustomMask({ params: { ...mask.params, path: nextPoints, - closed: getCustomMaskClosedStateAfterPointRemoval({ + closed: getFreeformPathClosedStateAfterPointRemoval({ wasClosed: mask.params.closed, remainingPointCount: nextPoints.length, }), @@ -46,11 +46,11 @@ function deletePointsFromElementMask({ const currentMasks = element.masks ?? []; let didDeletePoints = false; const nextMasks = currentMasks.map((mask) => { - if (mask.id !== maskId || mask.type !== "custom") { + if (mask.id !== maskId || mask.type !== "freeform") { return mask; } - const nextMask = deletePointsFromCustomMask({ + const nextMask = deletePointsFromFreeformPathMask({ mask, pointIds, }); @@ -64,7 +64,7 @@ function deletePointsFromElementMask({ }; } -export class DeleteCustomMaskPointsCommand extends Command { +export class DeleteFreeformPathMaskPointsCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; @@ -100,8 +100,9 @@ export class DeleteCustomMaskPointsCommand extends Command { elementId: this.elementId, elementPredicate: isMaskableElement, update: (element) => { + if (!isMaskableElement(element)) return element; const result = deletePointsFromElementMask({ - element: element as MaskableElement, + element, maskId: this.maskId, pointIds: this.pointIds, }); diff --git a/apps/web/src/commands/timeline/element/masks/index.ts b/apps/web/src/commands/timeline/element/masks/index.ts index dd6373b1..70b4333e 100644 --- a/apps/web/src/commands/timeline/element/masks/index.ts +++ b/apps/web/src/commands/timeline/element/masks/index.ts @@ -1,4 +1,4 @@ -export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points"; -export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point"; +export { DeleteFreeformPathMaskPointsCommand } from "./delete-custom-mask-points"; +export { InsertFreeformPathMaskPointCommand } from "./insert-custom-mask-point"; export { RemoveMaskCommand } from "./remove-mask"; export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts index 58559af6..0f714884 100644 --- a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts +++ b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts @@ -1,23 +1,23 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; -import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom"; +import { insertPointOnFreeformSegment } from "@/masks/freeform/definition"; import type { ElementBounds } from "@/preview/element-bounds"; -import type { CustomMask } from "@/masks/types"; +import type { FreeformPathMask } from "@/masks/types"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline"; -function insertPointIntoCustomMask({ +function insertPointIntoFreeformPathMask({ mask, segmentIndex, canvasPoint, bounds, }: { - mask: CustomMask; + mask: FreeformPathMask; segmentIndex: number; canvasPoint: { x: number; y: number }; bounds: ElementBounds; -}): { mask: CustomMask; insertedPointId: string | null } { - const result = insertPointOnCustomMaskSegment({ +}): { mask: FreeformPathMask; insertedPointId: string | null } { + const result = insertPointOnFreeformSegment({ params: mask.params, segmentIndex, canvasPoint, @@ -61,11 +61,11 @@ function insertPointIntoElementMask({ let didInsertPoint = false; const nextMasks = currentMasks.map((mask) => { - if (mask.id !== maskId || mask.type !== "custom") { + if (mask.id !== maskId || mask.type !== "freeform") { return mask; } - const result = insertPointIntoCustomMask({ + const result = insertPointIntoFreeformPathMask({ mask, segmentIndex, canvasPoint, @@ -85,7 +85,7 @@ function insertPointIntoElementMask({ }; } -export class InsertCustomMaskPointCommand extends Command { +export class InsertFreeformPathMaskPointCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; @@ -130,8 +130,9 @@ export class InsertCustomMaskPointCommand extends Command { elementId: this.elementId, elementPredicate: isMaskableElement, update: (element) => { + if (!isMaskableElement(element)) return element; const result = insertPointIntoElementMask({ - element: element as MaskableElement, + element, maskId: this.maskId, segmentIndex: this.segmentIndex, canvasPoint: this.canvasPoint, diff --git a/apps/web/src/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/commands/timeline/element/masks/remove-mask.ts index b543bce1..b92d1d8c 100644 --- a/apps/web/src/commands/timeline/element/masks/remove-mask.ts +++ b/apps/web/src/commands/timeline/element/masks/remove-mask.ts @@ -45,11 +45,13 @@ export class RemoveMaskCommand extends Command { trackId: this.trackId, elementId: this.elementId, elementPredicate: isMaskableElement, - update: (element) => - removeMaskFromElement({ - element: element as MaskableElement, + update: (element) => { + if (!isMaskableElement(element)) return element; + return removeMaskFromElement({ + element, maskId: this.maskId, - }), + }); + }, }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts index fec9b923..b53e24ca 100644 --- a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts +++ b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -56,11 +56,13 @@ export class ToggleMaskInvertedCommand extends Command { trackId: this.trackId, elementId: this.elementId, elementPredicate: isMaskableElement, - update: (element) => - toggleMaskInvertedOnElement({ - element: element as MaskableElement, + update: (element) => { + if (!isMaskableElement(element)) return element; + return toggleMaskInvertedOnElement({ + element, maskId: this.maskId, - }), + }); + }, }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts index 11c317f4..0a551fc3 100644 --- a/apps/web/src/commands/timeline/element/split-elements.ts +++ b/apps/web/src/commands/timeline/element/split-elements.ts @@ -9,12 +9,18 @@ import { EditorCore } from "@/core"; import { isRetimableElement } from "@/timeline"; import { splitAnimationsAtTime } from "@/animation"; import { getSourceSpanAtClipTime } from "@/retime"; +import { + addMediaTime, + type MediaTime, + roundMediaTime, + subMediaTime, +} from "@/wasm"; export class SplitElementsCommand extends Command { private savedState: SceneTracks | null = null; private rightSideElements: { trackId: string; elementId: string }[] = []; private readonly elements: { trackId: string; elementId: string }[]; - private readonly splitTime: number; + private readonly splitTime: MediaTime; private readonly retainSide: "both" | "left" | "right"; constructor({ @@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command { retainSide = "both", }: { elements: { trackId: string; elementId: string }[]; - splitTime: number; + splitTime: MediaTime; retainSide?: "both" | "left" | "right"; }) { super(); @@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command { return [element]; } - const relativeTime = this.splitTime - element.startTime; + const relativeTime = subMediaTime({ + a: this.splitTime, + b: element.startTime, + }); const leftVisibleDuration = relativeTime; - const rightVisibleDuration = element.duration - relativeTime; + const rightVisibleDuration = subMediaTime({ + a: element.duration, + b: relativeTime, + }); const retimeRef = isRetimableElement(element) ? element.retime : undefined; - const leftSourceSpan = getSourceSpanAtClipTime({ - clipTime: leftVisibleDuration, - retime: retimeRef, + // Snap the source-side split point exactly once and derive the right + // half from it. Independently rounding both spans (left and total) + // would let a 1-tick rounding error desynchronise them, breaking the + // invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`. + // See the same discipline in `compute-resize.ts` (snap-once comment). + const leftSourceSpan = roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: leftVisibleDuration, + retime: retimeRef, + }), + }); + const totalSourceSpan = roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: element.duration, + retime: retimeRef, + }), }); - const totalSourceSpan = getSourceSpanAtClipTime({ - clipTime: element.duration, - retime: retimeRef, + const rightSourceSpan = subMediaTime({ + a: totalSourceSpan, + b: leftSourceSpan, }); - const rightSourceSpan = totalSourceSpan - leftSourceSpan; const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ animations: element.animations, splitTime: relativeTime, @@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command { }); let splitResult: TimelineElement[]; + const leftTrimEnd = addMediaTime({ + a: element.trimEnd, + b: rightSourceSpan, + }); + const rightTrimStart = addMediaTime({ + a: element.trimStart, + b: leftSourceSpan, + }); + if (this.retainSide === "left") { splitResult = [ { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightSourceSpan, + trimEnd: leftTrimEnd, name: `${element.name} (left)`, animations: leftAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), @@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command { id: newId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftSourceSpan, + trimStart: rightTrimStart, name: `${element.name} (right)`, animations: rightAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), }, ]; } else { - // "both" - split into two pieces const secondElementId = generateUUID(); this.rightSideElements.push({ trackId: track.id, @@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command { { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightSourceSpan, + trimEnd: leftTrimEnd, name: `${element.name} (left)`, animations: leftAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), @@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command { id: secondElementId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftSourceSpan, + trimStart: rightTrimStart, name: `${element.name} (right)`, animations: rightAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), diff --git a/apps/web/src/commands/timeline/track/add-track.ts b/apps/web/src/commands/timeline/track/add-track.ts index 6b58b687..90becf13 100644 --- a/apps/web/src/commands/timeline/track/add-track.ts +++ b/apps/web/src/commands/timeline/track/add-track.ts @@ -11,14 +11,22 @@ export class AddTrackCommand extends Command { private trackId: string; private savedState: SceneTracks | null = null; - constructor( - private type: TrackType, - private index?: number, - ) { + constructor({ + type, + index, + }: { + type: TrackType; + index?: number; + }) { super(); + this.type = type; + this.index = index; this.trackId = generateUUID(); } + private type: TrackType; + private index?: number; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.scenes.getActiveScene().tracks; diff --git a/apps/web/src/commands/timeline/tracks-snapshot.ts b/apps/web/src/commands/timeline/tracks-snapshot.ts index 5b6d871a..8e55e602 100644 --- a/apps/web/src/commands/timeline/tracks-snapshot.ts +++ b/apps/web/src/commands/timeline/tracks-snapshot.ts @@ -3,13 +3,21 @@ import type { SceneTracks } from "@/timeline"; import { EditorCore } from "@/core"; export class TracksSnapshotCommand extends Command { - constructor( - private before: SceneTracks, - private after: SceneTracks, - ) { + constructor({ + before, + after, + }: { + before: SceneTracks; + after: SceneTracks; + }) { super(); + this.before = before; + this.after = after; } + private before: SceneTracks; + private after: SceneTracks; + execute(): CommandResult | undefined { EditorCore.getInstance().timeline.updateTracks(this.after); return undefined; diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 276cb490..aaae79a9 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -1,15 +1,24 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm"; +import { + formatTimecode, + type FrameRate, + type TimeCodeFormat, +} from "opencut-wasm"; import { cn } from "@/utils/ui"; +import { + parseMediaTimecode, + snapSeekMediaTime, + type MediaTime, +} from "@/wasm"; interface EditableTimecodeProps { - time: number; - duration: number; + time: MediaTime; + duration: MediaTime; format?: TimeCodeFormat; fps: FrameRate; - onTimeChange?: ({ time }: { time: number }) => void; + onTimeChange?: ({ time }: { time: MediaTime }) => void; className?: string; disabled?: boolean; } @@ -46,7 +55,11 @@ export function EditableTimecode({ }; const applyEdit = () => { - const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps }); + const parsedTime = parseMediaTimecode({ + timeCode: inputValue, + format, + fps, + }); if (parsedTime == null) { setHasError(true); @@ -54,7 +67,7 @@ export function EditableTimecode({ } const clampedTime = duration - ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime) + ? snapSeekMediaTime({ time: parsedTime, duration, fps }) : parsedTime; onTimeChange?.({ time: clampedTime }); diff --git a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx index 1539d305..b412de7b 100644 --- a/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx +++ b/apps/web/src/components/editor/panels/assets/assets-panel-store.tsx @@ -93,7 +93,7 @@ interface AssetsPanelStore { setMediaViewMode: (mode: MediaViewMode) => void; mediaSortBy: MediaSortKey; mediaSortOrder: MediaSortOrder; - setMediaSort: (key: MediaSortKey, order: MediaSortOrder) => void; + setMediaSort: (args: { key: MediaSortKey; order: MediaSortOrder }) => void; } export const useAssetsPanelStore = create<AssetsPanelStore>()( @@ -109,7 +109,7 @@ export const useAssetsPanelStore = create<AssetsPanelStore>()( setMediaViewMode: (mode) => set({ mediaViewMode: mode }), mediaSortBy: "name", mediaSortOrder: "asc", - setMediaSort: (key, order) => + setMediaSort: ({ key, order }) => set({ mediaSortBy: key, mediaSortOrder: order }), }), { diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 7cf89fbe..8cfb99c3 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -1,240 +1,241 @@ -"use client"; - -import { Plus } from "lucide-react"; -import { type ReactNode, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { AspectRatio } from "@/components/ui/aspect-ratio"; -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useEditor } from "@/editor/use-editor"; -import type { TimelineDragData } from "@/timeline/drag"; -import { cn } from "@/utils/ui"; - -export interface DraggableItemProps { - name: string; - preview: ReactNode; - dragData: TimelineDragData; - onDragStart?: ({ e }: { e: React.DragEvent }) => void; - onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; - aspectRatio?: number; - className?: string; - containerClassName?: string; - shouldShowPlusOnDrag?: boolean; - shouldShowLabel?: boolean; - isRounded?: boolean; - variant?: "card" | "compact"; - isDraggable?: boolean; -} - -export function DraggableItem({ - name, - preview, - dragData, - onDragStart, - onAddToTimeline, - aspectRatio = 16 / 9, - className = "", - containerClassName, - shouldShowPlusOnDrag = true, - shouldShowLabel = true, - isRounded = true, - variant = "card", - isDraggable = true, -}: DraggableItemProps) { - const [isDragging, setIsDragging] = useState(false); - const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); - const dragRef = useRef<HTMLDivElement>(null); - const editor = useEditor(); - - const handleAddToTimeline = () => { - onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); - }; - - const emptyImg = new window.Image(); - emptyImg.src = - "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; - - useEffect(() => { - if (!isDragging) return; - - const handleDragOver = (e: DragEvent) => { - setDragPosition({ x: e.clientX, y: e.clientY }); - }; - - document.addEventListener("dragover", handleDragOver); - - return () => { - document.removeEventListener("dragover", handleDragOver); - }; - }, [isDragging]); - - const handleDragStart = (event: React.DragEvent) => { - event.dataTransfer.setDragImage(emptyImg, 0, 0); - - editor.timeline.dragSource.begin({ - dataTransfer: event.dataTransfer, - dragData, - }); - - setDragPosition({ x: event.clientX, y: event.clientY }); - setIsDragging(true); - - onDragStart?.({ e: event }); - }; - - const handleDragEnd = () => { - setIsDragging(false); - editor.timeline.dragSource.end(); - }; - - return ( - <> - {variant === "card" ? ( - <div - ref={dragRef} - className={cn("group relative", containerClassName ?? "w-28")} - > - <div - className={cn( - "relative flex h-auto w-full cursor-default flex-col gap-1 p-", - className, - )} - > - <AspectRatio - ratio={aspectRatio} - className={cn( - "bg-accent relative overflow-hidden", - isRounded && "rounded-sm", - isDraggable && "[&::-webkit-drag-ghost]:opacity-0", - )} - draggable={isDraggable} - onDragStart={isDraggable ? handleDragStart : undefined} - onDragEnd={isDraggable ? handleDragEnd : undefined} - > - {preview} - {!isDragging && ( - <PlusButton - className="opacity-0 group-hover:opacity-100" - onClick={handleAddToTimeline} - /> - )} - </AspectRatio> - {shouldShowLabel && ( - <span - className="text-muted-foreground w-full truncate text-left text-[0.7rem]" - title={name} - > - <span className="sr-only">{name}</span> - <span aria-hidden="true"> - {name.length > 8 - ? `${name.slice(0, 16)}...${name.slice(-3)}` - : name} - </span> - </span> - )} - </div> - </div> - ) : ( - <div - ref={dragRef} - className={cn("group relative w-full", containerClassName)} - > - <button - type="button" - className={cn( - "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none", - isDraggable && "[&::-webkit-drag-ghost]:opacity-0", - className, - )} - draggable={isDraggable} - onDragStart={isDraggable ? handleDragStart : undefined} - onDragEnd={isDraggable ? handleDragEnd : undefined} - > - <div className="size-6 shrink-0 overflow-hidden rounded-sm"> - {preview} - </div> - <span className="w-full flex-1 truncate text-sm text-left"> - {name} - </span> - </button> - </div> - )} - - {isDraggable && - isDragging && - typeof document !== "undefined" && - createPortal( - <div - className="pointer-events-none fixed z-9999" - style={{ - left: dragPosition.x - 40, - top: dragPosition.y - 40, - }} - > - <div className="w-[80px]"> - <AspectRatio - ratio={1} - className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3" - > - <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover"> - {preview} - </div> - {shouldShowPlusOnDrag && ( - <PlusButton - onClick={handleAddToTimeline} - tooltipText="Add to timeline or drag to position" - /> - )} - </AspectRatio> - </div> - </div>, - document.body, - )} - </> - ); -} - -function PlusButton({ - className, - onClick, - tooltipText, -}: { - className?: string; - onClick?: () => void; - tooltipText?: string; -}) { - const button = ( - <Button - size="icon" - className={cn( - "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5", - className, - )} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onClick?.(); - }} - title={tooltipText} - > - <Plus /> - </Button> - ); - - if (tooltipText) { - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent> - <p>{tooltipText}</p> - </TooltipContent> - </Tooltip> - ); - } - - return button; -} +"use client";
+
+import { Plus } from "lucide-react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/editor/use-editor";
+import type { TimelineDragData } from "@/timeline/drag";
+import { cn } from "@/utils/ui";
+import type { MediaTime } from "@/wasm";
+
+export interface DraggableItemProps {
+ name: string;
+ preview: ReactNode;
+ dragData: TimelineDragData;
+ onDragStart?: ({ e }: { e: React.DragEvent }) => void;
+ onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
+ aspectRatio?: number;
+ className?: string;
+ containerClassName?: string;
+ shouldShowPlusOnDrag?: boolean;
+ shouldShowLabel?: boolean;
+ isRounded?: boolean;
+ variant?: "card" | "compact";
+ isDraggable?: boolean;
+}
+
+export function DraggableItem({
+ name,
+ preview,
+ dragData,
+ onDragStart,
+ onAddToTimeline,
+ aspectRatio = 16 / 9,
+ className = "",
+ containerClassName,
+ shouldShowPlusOnDrag = true,
+ shouldShowLabel = true,
+ isRounded = true,
+ variant = "card",
+ isDraggable = true,
+}: DraggableItemProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
+ const dragRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
+ };
+
+ const emptyImg = new window.Image();
+ emptyImg.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleDragOver = (e: DragEvent) => {
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ };
+
+ document.addEventListener("dragover", handleDragOver);
+
+ return () => {
+ document.removeEventListener("dragover", handleDragOver);
+ };
+ }, [isDragging]);
+
+ const handleDragStart = (event: React.DragEvent) => {
+ event.dataTransfer.setDragImage(emptyImg, 0, 0);
+
+ editor.timeline.dragSource.begin({
+ dataTransfer: event.dataTransfer,
+ dragData,
+ });
+
+ setDragPosition({ x: event.clientX, y: event.clientY });
+ setIsDragging(true);
+
+ onDragStart?.({ e: event });
+ };
+
+ const handleDragEnd = () => {
+ setIsDragging(false);
+ editor.timeline.dragSource.end();
+ };
+
+ return (
+ <>
+ {variant === "card" ? (
+ <div
+ ref={dragRef}
+ className={cn("group relative", containerClassName ?? "w-28")}
+ >
+ <div
+ className={cn(
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
+ className,
+ )}
+ >
+ <AspectRatio
+ ratio={aspectRatio}
+ className={cn(
+ "bg-accent relative overflow-hidden",
+ isRounded && "rounded-sm",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ {preview}
+ {!isDragging && (
+ <PlusButton
+ className="opacity-0 group-hover:opacity-100"
+ onClick={handleAddToTimeline}
+ />
+ )}
+ </AspectRatio>
+ {shouldShowLabel && (
+ <span
+ className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
+ title={name}
+ >
+ <span className="sr-only">{name}</span>
+ <span aria-hidden="true">
+ {name.length > 8
+ ? `${name.slice(0, 16)}...${name.slice(-3)}`
+ : name}
+ </span>
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div
+ ref={dragRef}
+ className={cn("group relative w-full", containerClassName)}
+ >
+ <button
+ type="button"
+ className={cn(
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ className,
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
+ {preview}
+ </div>
+ <span className="w-full flex-1 truncate text-sm text-left">
+ {name}
+ </span>
+ </button>
+ </div>
+ )}
+
+ {isDraggable &&
+ isDragging &&
+ typeof document !== "undefined" &&
+ createPortal(
+ <div
+ className="pointer-events-none fixed z-9999"
+ style={{
+ left: dragPosition.x - 40,
+ top: dragPosition.y - 40,
+ }}
+ >
+ <div className="w-[80px]">
+ <AspectRatio
+ ratio={1}
+ className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
+ >
+ <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
+ {preview}
+ </div>
+ {shouldShowPlusOnDrag && (
+ <PlusButton
+ onClick={handleAddToTimeline}
+ tooltipText="Add to timeline or drag to position"
+ />
+ )}
+ </AspectRatio>
+ </div>
+ </div>,
+ document.body,
+ )}
+ </>
+ );
+}
+
+function PlusButton({
+ className,
+ onClick,
+ tooltipText,
+}: {
+ className?: string;
+ onClick?: () => void;
+ tooltipText?: string;
+}) {
+ const button = (
+ <Button
+ size="icon"
+ className={cn(
+ "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
+ className,
+ )}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
+ title={tooltipText}
+ >
+ <Plus />
+ </Button>
+ );
+
+ if (tooltipText) {
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ );
+ }
+
+ return button;
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 4afd22b7..00cb015f 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -26,7 +26,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; import { useEditor } from "@/editor/use-editor"; import { useFileUpload } from "@/media/use-file-upload"; import { invokeAction } from "@/actions"; @@ -139,9 +139,12 @@ export function MediaView() { const handleSort = ({ key }: { key: MediaSortKey }) => { if (mediaSortBy === key) { - setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc"); + setMediaSort({ + key, + order: mediaSortOrder === "asc" ? "desc" : "asc", + }); } else { - setMediaSort(key, "asc"); + setMediaSort({ key, order: "asc" }); } }; @@ -255,11 +258,11 @@ function MediaAssetDraggable({ startTime, }: { asset: MediaAsset; - startTime: number; + startTime: MediaTime; }) => { const duration = asset.duration != null - ? Math.round(asset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: asset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const element = buildElementFromMedia({ mediaId: asset.id, diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx index bf3a1c16..5edbeab3 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { Select, @@ -34,6 +34,10 @@ import type { TCanvasSize } from "@/project/types"; type SettingsView = "project-info" | "background"; +function isSettingsView(value: string): value is SettingsView { + return value === "project-info" || value === "background"; +} + const PRESET_LABELS: Record<string, string> = { "1:1": "1:1", "16:9": "16:9", @@ -73,23 +77,20 @@ function useCanvasDimensionDraft({ value: number; onCommit: (value: number) => void; }) { - const pendingValueRef = useRef(value); - const syncedValueRef = useRef(value); - - if (syncedValueRef.current !== value) { - syncedValueRef.current = value; - pendingValueRef.current = value; - } + const [pendingValue, setPendingValue] = useState(value); return usePropertyDraft({ displayValue: formatCanvasDimension({ value }), parse: (input) => parseCanvasDimension({ input }), + onStartEditing: () => { + setPendingValue(value); + }, onPreview: (nextValue) => { - pendingValueRef.current = nextValue; + setPendingValue(nextValue); }, onCommit: () => { - if (pendingValueRef.current !== value) { - onCommit(pendingValueRef.current); + if (pendingValue !== value) { + onCommit(pendingValue); } }, }); @@ -212,7 +213,14 @@ export function SettingsView() { contentClassName="px-0" scrollClassName="pt-0" actions={ - <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> + <Tabs + value={view} + onValueChange={(value) => { + if (isSettingsView(value)) { + setView(value); + } + }} + > <TabsList> <TabsTrigger value="project-info">Project info</TabsTrigger> <TabsTrigger value="background">Background</TabsTrigger> diff --git a/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx new file mode 100644 index 00000000..2befcef4 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/components/element-params-tab.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { resolveAnimationPathValueAtTime } from "@/animation"; +import { Section, SectionContent, SectionFields } from "@/components/section"; +import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; +import { useKeyframedParamProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; +import { PropertyParamField } from "@/components/editor/panels/properties/components/property-param-field"; +import type { ParamValue, ParamValues } from "@/params"; +import { + getElementParams, + readElementParamValue, + writeElementParamValue, + type ElementParamDefinition, +} from "@/params/registry"; +import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; + +export function ElementParamsTab({ + element, + trackId, + paramKeys, + sectionKey, +}: { + element: TimelineElement; + trackId: string; + paramKeys?: readonly string[]; + sectionKey: string; +}) { + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const params = getElementParams({ element }).filter( + (param) => !paramKeys || paramKeys.includes(param.key), + ); + const baseValues = buildValues({ element, params }); + + return ( + <Section sectionKey={`${element.id}:${sectionKey}`}> + <SectionContent className="pt-4"> + <SectionFields> + {params + .filter((param) => isVisible({ param, values: baseValues })) + .map((param) => ( + <ElementParamField + key={param.key} + element={element} + trackId={trackId} + param={param} + baseValue={baseValues[param.key] ?? param.default} + localTime={localTime} + isPlayheadWithinElementRange={isPlayheadWithinElementRange} + /> + ))} + </SectionFields> + </SectionContent> + </Section> + ); +} + +function ElementParamField({ + element, + trackId, + param, + baseValue, + localTime, + isPlayheadWithinElementRange, +}: { + element: TimelineElement; + trackId: string; + param: ElementParamDefinition; + baseValue: ParamValue; + localTime: MediaTime; + isPlayheadWithinElementRange: boolean; +}) { + const resolvedValue = resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath: param.key, + localTime, + fallbackValue: baseValue, + }); + const animatedParam = useKeyframedParamProperty({ + param, + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: param.key, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates: ({ value }) => + writeElementParamValue({ element, param, value }), + }); + + return ( + <PropertyParamField + param={param} + value={resolvedValue} + onPreview={animatedParam.onPreview} + onCommit={animatedParam.onCommit} + keyframe={ + param.keyframable === false + ? undefined + : { + isActive: animatedParam.isKeyframedAtTime, + isDisabled: !isPlayheadWithinElementRange, + onToggle: animatedParam.toggleKeyframe, + } + } + /> + ); +} + +function buildValues({ + element, + params, +}: { + element: TimelineElement; + params: readonly ElementParamDefinition[]; +}): ParamValues { + const values: ParamValues = {}; + for (const param of params) { + const value = readElementParamValue({ element, param }); + if (value !== null) { + values[param.key] = value; + } + } + return values; +} + +function isVisible({ + param, + values, +}: { + param: ElementParamDefinition; + values: ParamValues; +}): boolean { + return (param.dependencies ?? []).every((dependency) => + areParamValuesEqual({ + left: values[dependency.param], + right: dependency.equals, + }), + ); +} + +function areParamValuesEqual({ + left, + right, +}: { + left: ParamValue | undefined; + right: ParamValue; +}): boolean { + return left === right; +} diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx index c9a9c22e..66225903 100644 --- a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx +++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx @@ -1,6 +1,10 @@ "use client"; -import type { ParamDefinition, NumberParamDefinition } from "@/params"; +import type { + ParamDefinition, + NumberParamDefinition, + ParamValue, +} from "@/params"; import { formatNumberForDisplay, getFractionDigitsForStep, @@ -19,6 +23,7 @@ import { } from "@/components/ui/select"; import { usePropertyDraft } from "../hooks/use-property-draft"; import { KeyframeToggle } from "./keyframe-toggle"; +import { Textarea } from "@/components/ui/textarea"; export function PropertyParamField({ param, @@ -28,8 +33,8 @@ export function PropertyParamField({ keyframe, }: { param: ParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; + value: ParamValue; + onPreview: (value: ParamValue) => void; onCommit: () => void; keyframe?: { isActive: boolean; @@ -41,7 +46,7 @@ export function PropertyParamField({ <SectionField label={param.label} beforeLabel={ - keyframe ? ( + keyframe && param.keyframable !== false ? ( <KeyframeToggle isActive={keyframe.isActive} isDisabled={keyframe.isDisabled} @@ -68,8 +73,8 @@ function ParamInput({ onCommit, }: { param: ParamDefinition; - value: number | string | boolean; - onPreview: (value: number | string | boolean) => void; + value: ParamValue; + onPreview: (value: ParamValue) => void; onCommit: () => void; }) { if (param.type === "number") { @@ -131,6 +136,27 @@ function ParamInput({ ); } + if (param.type === "text") { + return ( + <Textarea + value={String(value)} + onChange={(event) => onPreview(event.currentTarget.value)} + onBlur={onCommit} + /> + ); + } + + if (param.type === "font") { + return ( + <input + className="border-input bg-accent h-9 w-full rounded-md border px-3 text-sm outline-none" + value={String(value)} + onChange={(event) => onPreview(event.currentTarget.value)} + onBlur={onCommit} + /> + ); + } + return null; } diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 967156b3..83e31aec 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -1,22 +1,25 @@ import { useEditor } from "@/editor/use-editor"; import { getElementLocalTime } from "@/animation"; +import { addMediaTime, mediaTime, type MediaTime } from "@/wasm"; export function useElementPlayhead({ startTime, duration, }: { - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; }) { const playheadTime = useEditor((editor) => editor.playback.getCurrentTime()); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: startTime, - elementDuration: duration, + const localTime = mediaTime({ + ticks: getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: startTime, + elementDuration: duration, + }), }); const isPlayheadWithinElementRange = playheadTime >= startTime && - playheadTime <= startTime + duration; + playheadTime <= addMediaTime({ a: startTime, b: duration }); return { localTime, isPlayheadWithinElementRange }; } diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts deleted file mode 100644 index 80fc171b..00000000 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useEditor } from "@/editor/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, -} from "@/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import { upsertElementKeyframe } from "@/timeline/animation-properties"; - -export function useKeyframedColorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedColor, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedColor: string; - buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>; -}) { - const editor = useEditor(); - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const onChange = ({ color }: { color: string }) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: color, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], - }); - }; - - const onChangeEnd = () => editor.timeline.commitPreview(); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, - ], - }); - }; - - return { - isKeyframedAtTime, - hasAnimatedKeyframes, - keyframeIdAtTime, - onChange, - onChangeEnd, - toggleKeyframe, - }; -} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts deleted file mode 100644 index 32222b7a..00000000 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { useEditor } from "@/editor/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, -} from "@/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; -import type { TimelineElement } from "@/timeline"; -import { upsertElementKeyframe } from "@/timeline/animation-properties"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedNumberProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - displayValue, - parse, - valueAtPlayhead, - step, - buildBaseUpdates, - buildAdditionalKeyframes, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - displayValue: string; - parse: (input: string) => number | null; - valueAtPlayhead: number; - step?: number; - buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>; - buildAdditionalKeyframes?: ({ - value, - }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>; -}) { - const editor = useEditor(); - const snapValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; - const updatedAnimations = [ - { propertyPath, value: nextValue }, - ...additionalKeyframes, - ].reduce( - (currentAnimations, keyframe) => - upsertElementKeyframe({ - animations: currentAnimations, - propertyPath: keyframe.propertyPath, - time: localTime, - value: keyframe.value, - }), - animations, - ); - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { animations: updatedAnimations }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - const propertyDraft = usePropertyDraft({ - displayValue, - parse: (input) => { - const parsedValue = parse(input); - return parsedValue === null ? null : snapValue(parsedValue); - }, - onPreview: (value) => previewValue({ value }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: snapValue(valueAtPlayhead), - }, - ], - }); - }; - - const commitValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: nextValue }, - ...additionalKeyframes.map((keyframe) => ({ - trackId, - elementId, - propertyPath: keyframe.propertyPath, - time: localTime, - value: keyframe.value, - })), - ], - }); - return; - } - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId, - patch: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - return { - ...propertyDraft, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitValue, - }; -} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 18e027c7..0a0f7db0 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -8,15 +8,16 @@ import { upsertPathKeyframe, } from "@/animation"; import type { + AnimationPath, ElementAnimations, } from "@/animation/types"; import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamValueKind, -} from "@/animation/animated-params"; -import type { ParamDefinition } from "@/params"; + coerceParamValue, + getParamChannelLayout, + type ParamDefinition, +} from "@/params"; import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface KeyframedParamPropertyResult { hasAnimatedKeyframes: boolean; @@ -32,6 +33,7 @@ export function useKeyframedParamProperty({ trackId, elementId, animations, + propertyPath, localTime, isPlayheadWithinElementRange, resolvedValue, @@ -41,7 +43,8 @@ export function useKeyframedParamProperty({ trackId: string; elementId: string; animations: ElementAnimations | undefined; - localTime: number; + propertyPath?: AnimationPath; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedValue: number | string | boolean; buildBaseUpdates: ({ @@ -51,15 +54,16 @@ export function useKeyframedParamProperty({ }) => Partial<TimelineElement>; }): KeyframedParamPropertyResult { const editor = useEditor(); - const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const resolvedPropertyPath = + propertyPath ?? buildGraphicParamPath({ paramKey: param.key }); const hasAnimatedKeyframes = hasKeyframesForPath({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, }); const keyframeAtTime = isPlayheadWithinElementRange ? getKeyframeAtTime({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, }) : null; @@ -78,15 +82,12 @@ export function useKeyframedParamProperty({ updates: { animations: upsertPathKeyframe({ animations, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, value, - kind: getAnimationParamValueKind({ param }), - defaultInterpolation: getAnimationParamDefaultInterpolation({ - param, - }), + channelLayout: getParamChannelLayout({ param }), coerceValue: ({ value: nextValue }) => - coerceAnimationParamValue({ + coerceParamValue({ param, value: nextValue, }), @@ -120,7 +121,7 @@ export function useKeyframedParamProperty({ { trackId, elementId, - propertyPath, + propertyPath: resolvedPropertyPath, keyframeId: keyframeIdAtTime, }, ], @@ -133,7 +134,7 @@ export function useKeyframedParamProperty({ { trackId, elementId, - propertyPath, + propertyPath: resolvedPropertyPath, time: localTime, value: resolvedValue, }, diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts index 0e7ae6a7..7e8f6e40 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -1,4 +1,4 @@ -import { useReducer, useRef } from "react";
+import { useState } from "react";
import { evaluateMathExpression } from "@/utils/math";
function looksLikeExpression({ input }: { input: string }): boolean {
@@ -14,59 +14,56 @@ export function usePropertyDraft<T>({ parse,
onPreview,
onCommit,
+ onStartEditing,
supportsExpressions = true,
}: {
displayValue: string;
parse: (input: string) => T | null;
onPreview: (value: T) => void;
onCommit: () => void;
+ onStartEditing?: () => void;
supportsExpressions?: boolean;
}) {
- const [, forceRender] = useReducer(
- (renderVersion: number) => renderVersion + 1,
- 0,
- );
- const isEditing = useRef(false);
- const draft = useRef("");
+ const [isEditing, setIsEditing] = useState(false);
+ const [draft, setDraft] = useState("");
return {
- displayValue: isEditing.current ? draft.current : sourceDisplay,
+ displayValue: isEditing ? draft : sourceDisplay,
scrubTo: (value: number) => {
const parsed = parse(String(value));
if (parsed !== null) onPreview(parsed);
},
commitScrub: onCommit,
onFocus: () => {
- isEditing.current = true;
- draft.current = sourceDisplay;
- forceRender();
+ setIsEditing(true);
+ setDraft(sourceDisplay);
+ onStartEditing?.();
},
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
- draft.current = event.target.value;
- forceRender();
+ const nextDraft = event.target.value;
+ setDraft(nextDraft);
- const parsed = parse(event.target.value);
+ const parsed = parse(nextDraft);
if (parsed !== null) {
onPreview(parsed);
}
},
- onBlur: () => {
- if (
- supportsExpressions &&
- looksLikeExpression({ input: draft.current })
- ) {
- const evaluated = evaluateMathExpression({ input: draft.current });
+ onBlur: (
+ event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
+ ) => {
+ const nextDraft = event.target.value;
+ if (supportsExpressions && looksLikeExpression({ input: nextDraft })) {
+ const evaluated = evaluateMathExpression({ input: nextDraft });
if (evaluated !== null) {
const parsed = parse(String(evaluated));
if (parsed !== null) onPreview(parsed);
}
}
onCommit();
- isEditing.current = false;
- draft.current = "";
- forceRender();
+ setIsEditing(false);
+ setDraft("");
},
};
}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 52fa42b5..d946027d 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -71,7 +71,12 @@ export function PropertiesPanel() { <Button variant={tab.id === activeTab.id ? "secondary" : "ghost"} size="icon" - onClick={() => setActiveTab(element.type, tab.id)} + onClick={() => + setActiveTab({ + elementType: element.type, + tabId: tab.id, + }) + } aria-label={tab.label} className={cn( "shrink-0", diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index f9e40ab3..1eda6575 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -22,16 +22,43 @@ import { MagicWand05Icon, DashboardSpeed02Icon, } from "@hugeicons/core-free-icons"; -import { TransformTab } from "@/rendering/components/transform-tab"; -import { BlendingTab } from "@/rendering/components/blending-tab"; -import { AudioTab } from "@/timeline/components/audio-tab"; -import { TextTab } from "@/text/components/text-tab"; +import { ElementParamsTab } from "./components/element-params-tab"; import { ClipEffectsTab, StandaloneEffectTab } from "@/effects/components/effects-tab"; import { MasksTab } from "@/masks/components/masks-tab"; import { SpeedTab } from "@/speed/components/speed-tab"; import { GraphicTab } from "@/graphics/components/graphic-tab"; import { OcShapesIcon } from "@/components/icons"; +const TRANSFORM_PARAM_KEYS = [ + "transform.positionX", + "transform.positionY", + "transform.scaleX", + "transform.scaleY", + "transform.rotate", +] as const; + +const BLENDING_PARAM_KEYS = ["opacity", "blendMode"] as const; +const AUDIO_PARAM_KEYS = ["volume", "muted"] as const; +const TEXT_PARAM_KEYS = [ + "content", + "fontFamily", + "fontSize", + "color", + "textAlign", + "fontWeight", + "fontStyle", + "textDecoration", + "letterSpacing", + "lineHeight", + "background.enabled", + "background.color", + "background.cornerRadius", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", +] as const; + export type TabContentProps = { trackId: string; }; @@ -58,7 +85,12 @@ function buildTransformTab({ label: "Transform", icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, content: ({ trackId }) => ( - <TransformTab element={element} trackId={trackId} /> + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={TRANSFORM_PARAM_KEYS} + sectionKey="transform" + /> ), }; } @@ -73,7 +105,12 @@ function buildBlendingTab({ label: "Blending", icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, content: ({ trackId }) => ( - <BlendingTab element={element} trackId={trackId} /> + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={BLENDING_PARAM_KEYS} + sectionKey="blending" + /> ), }; } @@ -87,7 +124,14 @@ function buildAudioTab({ id: "audio", label: "Audio", icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, - content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, + content: ({ trackId }) => ( + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={AUDIO_PARAM_KEYS} + sectionKey="audio" + /> + ), }; } @@ -137,7 +181,14 @@ function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { id: "text", label: "Text", icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, - content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, + content: ({ trackId }) => ( + <ElementParamsTab + element={element} + trackId={trackId} + paramKeys={TEXT_PARAM_KEYS} + sectionKey="text" + /> + ), }; } diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts index bd57d4b0..f6304349 100644 --- a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts +++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts @@ -2,17 +2,18 @@ import { create } from "zustand"; interface PropertiesState {
activeTabPerType: Record<string, string>;
- setActiveTab: (elementType: string, tabId: string) => void;
+ setActiveTab: (args: { elementType: string; tabId: string }) => void;
isTransformScaleLocked: boolean;
- setTransformScaleLocked: (locked: boolean) => void;
+ setTransformScaleLocked: (args: { locked: boolean }) => void;
}
export const usePropertiesStore = create<PropertiesState>()((set) => ({
activeTabPerType: {},
- setActiveTab: (elementType, tabId) =>
+ setActiveTab: ({ elementType, tabId }) =>
set((state) => ({
activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
})),
isTransformScaleLocked: false,
- setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+ setTransformScaleLocked: ({ locked }) =>
+ set({ isTransformScaleLocked: locked }),
}));
diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index 3e55e1d6..0977666f 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -39,7 +39,7 @@ export class EditorCore { this.project = new ProjectManager(this); this.media = new MediaManager(this); this.renderer = new RendererManager(this); - this.save = new SaveManager(this); + this.save = new SaveManager({ editor: this }); this.audio = new AudioManager(this); this.selection = new SelectionManager(this); this.clipboard = new ClipboardManager(this); diff --git a/apps/web/src/core/managers/clipboard-manager.ts b/apps/web/src/core/managers/clipboard-manager.ts index 8a2a51c9..283a78ae 100644 --- a/apps/web/src/core/managers/clipboard-manager.ts +++ b/apps/web/src/core/managers/clipboard-manager.ts @@ -6,6 +6,7 @@ import { type CopyContext, type PasteContext, } from "@/clipboard"; +import type { MediaTime } from "@/wasm"; export class ClipboardManager { private entry: ClipboardEntry | null = null; @@ -34,7 +35,7 @@ export class ClipboardManager { return true; } - paste({ time }: { time?: number } = {}): boolean { + paste({ time }: { time?: MediaTime } = {}): boolean { if (!this.entry) { return false; } @@ -64,7 +65,7 @@ export class ClipboardManager { }; } - private getPasteContext({ time }: { time?: number }): PasteContext { + private getPasteContext({ time }: { time?: MediaTime }): PasteContext { return { editor: this.editor, selectedElements: this.editor.selection.getSelectedElements(), diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts index 553d1557..69da6e80 100644 --- a/apps/web/src/core/managers/media-manager.ts +++ b/apps/web/src/core/managers/media-manager.ts @@ -68,9 +68,17 @@ export class MediaManager { const command = uniqueIds.length === 1 - ? new RemoveMediaAssetCommand(projectId, uniqueIds[0]) + ? new RemoveMediaAssetCommand({ + projectId, + assetId: uniqueIds[0], + }) : new BatchCommand( - uniqueIds.map((id) => new RemoveMediaAssetCommand(projectId, id)), + uniqueIds.map((id) => + new RemoveMediaAssetCommand({ + projectId, + assetId: id, + }), + ), ); this.editor.command.execute({ command }); diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index a243dc19..9b9d26bc 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -1,20 +1,26 @@ import type { EditorCore } from "@/core"; -import { TICKS_PER_SECOND } from "@/wasm"; -import { roundToFrame } from "opencut-wasm"; +import { + addMediaTime, + clampMediaTime, + type MediaTime, + mediaTimeFromSeconds, + roundFrameTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class PlaybackManager { private isPlaying = false; - private currentTime = 0; + private currentTime: MediaTime = ZERO_MEDIA_TIME; private volume = 1; private muted = false; private previousVolume = 1; private isScrubbing = false; private listeners = new Set<() => void>(); - private updateListeners = new Set<(time: number) => void>(); - private seekListeners = new Set<(time: number) => void>(); + private updateListeners = new Set<(time: MediaTime) => void>(); + private seekListeners = new Set<(time: MediaTime) => void>(); private playbackTimer: number | null = null; private playbackStartWallTime = 0; - private playbackStartTime = 0; + private playbackStartTime: MediaTime = ZERO_MEDIA_TIME; private timelineScopeBound = false; constructor(private editor: EditorCore) {} @@ -40,7 +46,7 @@ export class PlaybackManager { } if (this.currentTime >= maxTime) { - this.seek({ time: 0 }); + this.seek({ time: ZERO_MEDIA_TIME }); } this.isPlaying = true; @@ -62,7 +68,7 @@ export class PlaybackManager { } } - seek({ time }: { time: number }): void { + seek({ time }: { time: MediaTime }): void { this.currentTime = this.clampTimeToTimeline(time); if (this.isPlaying) { this.playbackStartWallTime = performance.now(); @@ -109,7 +115,7 @@ export class PlaybackManager { return this.isPlaying; } - getCurrentTime(): number { + getCurrentTime(): MediaTime { return this.currentTime; } @@ -135,12 +141,12 @@ export class PlaybackManager { return () => this.listeners.delete(listener); } - onUpdate(listener: (time: number) => void): () => void { + onUpdate(listener: (time: MediaTime) => void): () => void { this.updateListeners.add(listener); return () => this.updateListeners.delete(listener); } - onSeek(listener: (time: number) => void): () => void { + onSeek(listener: (time: MediaTime) => void): () => void { this.seekListeners.add(listener); return () => this.seekListeners.delete(listener); } @@ -165,6 +171,7 @@ export class PlaybackManager { if (timeChanged) { this.notifySeek(this.currentTime); + this.dispatchSeekEvent(this.currentTime); } } @@ -174,6 +181,18 @@ export class PlaybackManager { }); } + private notifyUpdate(time: MediaTime): void { + this.updateListeners.forEach((fn) => { + fn(time); + }); + } + + private notifySeek(time: MediaTime): void { + this.seekListeners.forEach((fn) => { + fn(time); + }); + } + private startTimer(): void { if (this.playbackTimer) { cancelAnimationFrame(this.playbackTimer); @@ -197,40 +216,42 @@ export class PlaybackManager { const fps = this.editor.project.getActive()?.settings.fps; const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000; - const rawTime = - this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND); - const newTime = fps - ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) - : rawTime; + const rawTime = addMediaTime({ + a: this.playbackStartTime, + b: mediaTimeFromSeconds({ seconds: elapsedSeconds }), + }); + const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime; const maxTime = this.editor.timeline.getTotalDuration(); if (newTime >= maxTime) { this.pause(); this.currentTime = maxTime; this.notify(); - this.notifySeek(maxTime); - return; + this.notifySeek(maxTime); + this.dispatchSeekEvent(maxTime); + return; } this.currentTime = newTime; this.notifyUpdate(newTime); + this.dispatchUpdateEvent(newTime); this.playbackTimer = requestAnimationFrame(this.updateTime); }; - private clampTimeToTimeline(time: number): number { + private clampTimeToTimeline(time: MediaTime): MediaTime { const maxTime = this.editor.timeline.getTotalDuration(); - return Math.max(0, Math.min(maxTime, time)); + return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime }); } - private notifySeek(time: number): void { - for (const fn of this.seekListeners) { - fn(time); + private dispatchSeekEvent(time: MediaTime): void { + if (typeof window === "undefined") { + return; } } - private notifyUpdate(time: number): void { - for (const fn of this.updateListeners) { - fn(time); + private dispatchUpdateEvent(time: MediaTime): void { + if (typeof window === "undefined") { + return; } } } diff --git a/apps/web/src/core/managers/save-manager.ts b/apps/web/src/core/managers/save-manager.ts index b3c894d4..8cb2275a 100644 --- a/apps/web/src/core/managers/save-manager.ts +++ b/apps/web/src/core/managers/save-manager.ts @@ -12,13 +12,18 @@ export class SaveManager { private saveTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeHandlers: Array<() => void> = [];
- constructor(
- private editor: EditorCore,
- { debounceMs = 800 }: SaveManagerOptions = {},
- ) {
+ constructor({
+ editor,
+ debounceMs = 800,
+ }: {
+ editor: EditorCore;
+ } & SaveManagerOptions) {
+ this.editor = editor;
this.debounceMs = debounceMs;
}
+ private editor: EditorCore;
+
start(): void {
if (this.unsubscribeHandlers.length > 0) return;
diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index 13f251a1..a4c364ce 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -1,5 +1,5 @@ import type { EditorCore } from "@/core"; -import type { SceneTracks, TScene } from "@/timeline"; +import type { Bookmark, SceneTracks, TScene } from "@/timeline"; import { storageService } from "@/services/storage/service"; import { getMainScene, @@ -21,6 +21,7 @@ import { ToggleBookmarkCommand, UpdateBookmarkCommand, } from "@/commands/scene"; +import type { MediaTime } from "@/wasm"; export class ScenesManager { private active: TScene | null = null; @@ -40,7 +41,7 @@ export class ScenesManager { throw new Error("No active project"); } - const command = new CreateSceneCommand(name, isMain); + const command = new CreateSceneCommand({ name, isMain }); this.editor.command.execute({ command }); return command.getSceneId(); } @@ -76,7 +77,10 @@ export class ScenesManager { throw new Error("No active project"); } - const command = new RenameSceneCommand(sceneId, name); + const command = new RenameSceneCommand({ + sceneId, + newName: name, + }); this.editor.command.execute({ command }); } @@ -106,12 +110,12 @@ export class ScenesManager { this.notify(); } - async toggleBookmark({ time }: { time: number }): Promise<void> { + async toggleBookmark({ time }: { time: MediaTime }): Promise<void> { const command = new ToggleBookmarkCommand(time); this.editor.command.execute({ command }); } - isBookmarked({ time }: { time: number }): boolean { + isBookmarked({ time }: { time: MediaTime }): boolean { const activeScene = this.getActiveScene(); const activeProject = this.editor.project.getActive(); @@ -125,7 +129,7 @@ export class ScenesManager { return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime }); } - async removeBookmark({ time }: { time: number }): Promise<void> { + async removeBookmark({ time }: { time: MediaTime }): Promise<void> { const command = new RemoveBookmarkCommand(time); this.editor.command.execute({ command }); } @@ -134,10 +138,10 @@ export class ScenesManager { time, updates, }: { - time: number; - updates: Partial<{ note: string; color: string; duration: number }>; + time: MediaTime; + updates: Partial<Omit<Bookmark, "time">>; }): Promise<void> { - const command = new UpdateBookmarkCommand(time, updates); + const command = new UpdateBookmarkCommand({ time, updates }); this.editor.command.execute({ command }); } @@ -145,14 +149,14 @@ export class ScenesManager { fromTime, toTime, }: { - fromTime: number; - toTime: number; + fromTime: MediaTime; + toTime: MediaTime; }): Promise<void> { - const command = new MoveBookmarkCommand(fromTime, toTime); + const command = new MoveBookmarkCommand({ fromTime, toTime }); this.editor.command.execute({ command }); } - getBookmarkAtTime({ time }: { time: number }) { + getBookmarkAtTime({ time }: { time: MediaTime }) { const activeScene = this.active; const activeProject = this.editor.project.getActive(); diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index a2bf7451..6fb28625 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -11,23 +11,23 @@ import type { import { calculateTotalDuration } from "@/timeline"; import { TimelineDragSource } from "@/timeline/drag-source"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; +import { lastFrameMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; import { canElementBeHidden, canElementHaveAudio, } from "@/timeline/element-utils"; +import { isElementMuted } from "@/timeline/audio-state"; import type { AnimationPath, AnimationInterpolation, - AnimationValue, - AnimationValueForPath, ScalarCurveKeyframePatch, } from "@/animation/types"; +import type { ParamValue } from "@/params"; import { getElementLocalTime, resolveAnimationPathValueAtTime, } from "@/animation"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; -import { lastFrameTime } from "opencut-wasm"; import { BatchCommand } from "@/commands"; import { AddTrackCommand, @@ -46,8 +46,8 @@ import { RetimeKeyframeCommand, UpdateScalarKeyframeCurveCommand, AddClipEffectCommand, - DeleteCustomMaskPointsCommand, - InsertCustomMaskPointCommand, + DeleteFreeformPathMaskPointsCommand, + InsertFreeformPathMaskPointCommand, RemoveClipEffectCommand, UpdateClipEffectParamsCommand, ToggleClipEffectCommand, @@ -73,7 +73,7 @@ export class TimelineManager { constructor(private editor: EditorCore) {} addTrack({ type, index }: { type: TrackType; index?: number }): string { - const command = new AddTrackCommand(type, index); + const command = new AddTrackCommand({ type, index }); this.editor.command.execute({ command }); return command.getTrackId(); } @@ -97,10 +97,10 @@ export class TimelineManager { pushHistory = true, }: { elementId: string; - trimStart: number; - trimEnd: number; - startTime?: number; - duration?: number; + trimStart: MediaTime; + trimEnd: MediaTime; + startTime?: MediaTime; + duration?: MediaTime; pushHistory?: boolean; }): void { const trackId = this.findTrackIdForElement({ elementId }); @@ -190,7 +190,7 @@ export class TimelineManager { retainSide = "both", }: { elements: { trackId: string; elementId: string }[]; - splitTime: number; + splitTime: MediaTime; retainSide?: "both" | "left" | "right"; }): { trackId: string; elementId: string }[] { const command = new SplitElementsCommand({ @@ -202,20 +202,20 @@ export class TimelineManager { return command.getRightSideElements(); } - getTotalDuration(): number { + getTotalDuration(): MediaTime { const activeScene = this.editor.scenes.getActiveSceneOrNull(); if (!activeScene) { - return 0; + return ZERO_MEDIA_TIME; } return calculateTotalDuration({ tracks: activeScene.tracks }); } - getLastFrameTime(): number { + getLastFrameTime(): MediaTime { const duration = this.getTotalDuration(); const fps = this.editor.project.getActive()?.settings.fps; if (!fps || duration <= 0) return duration; - return lastFrameTime({ duration, rate: fps }) ?? duration; + return lastFrameMediaTime({ duration, fps }); } getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { @@ -349,7 +349,7 @@ export class TimelineManager { this.editor.command.execute({ command }); } - deleteCustomMaskPoints({ + deleteFreeformPathMaskPoints({ trackId, elementId, maskId, @@ -363,7 +363,7 @@ export class TimelineManager { if (pointIds.length === 0) { return; } - const command = new DeleteCustomMaskPointsCommand({ + const command = new DeleteFreeformPathMaskPointsCommand({ trackId, elementId, maskId, @@ -372,7 +372,7 @@ export class TimelineManager { this.editor.command.execute({ command }); } - insertCustomMaskPoint({ + insertFreeformPathMaskPoint({ trackId, elementId, maskId, @@ -387,7 +387,7 @@ export class TimelineManager { canvasPoint: { x: number; y: number }; bounds: ElementBounds; }): void { - const command = new InsertCustomMaskPointCommand({ + const command = new InsertFreeformPathMaskPointCommand({ trackId, elementId, maskId, @@ -485,8 +485,8 @@ export class TimelineManager { trackId: string; elementId: string; propertyPath: AnimationPath; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; }>; @@ -537,7 +537,7 @@ export class TimelineManager { // Pre-sample values at playhead for each (element, property) pair. // This preserves "what you see is what you get" when all keyframes are deleted. const playheadTime = this.editor.playback.getCurrentTime(); - const valueAtPlayheadMap = new Map<string, AnimationValue | null>(); + const valueAtPlayheadMap = new Map<string, ParamValue | null>(); for (const { trackId, elementId, propertyPath } of keyframes) { const key = `${elementId}:${propertyPath}`; @@ -558,9 +558,7 @@ export class TimelineManager { }); const target = resolveAnimationTarget({ element, path: propertyPath }); - const baseValue = - (target?.getBaseValue() as AnimationValueForPath<AnimationPath> | null) ?? - null; + const baseValue = target?.getBaseValue() ?? null; if (baseValue === null) { valueAtPlayheadMap.set(key, null); continue; @@ -602,7 +600,7 @@ export class TimelineManager { elementId: string; propertyPath: AnimationPath; keyframeId: string; - time: number; + time: MediaTime; }): void { const command = new RetimeKeyframeCommand({ trackId, @@ -660,7 +658,7 @@ export class TimelineManager { elementId: string; effectId: string; paramKey: string; - time: number; + time: MediaTime; value: number; interpolation?: "linear" | "hold"; keyframeId?: string; @@ -709,9 +707,9 @@ export class TimelineManager { updates, }: { updates: readonly { - readonly trackId: string; - readonly elementId: string; - readonly updates: Partial<TimelineElement>; + trackId: string; + elementId: string; + updates: Partial<TimelineElement>; }[]; }): void { let changedOverlayCount = 0; @@ -751,7 +749,10 @@ export class TimelineManager { } const afterTracks = this.previewTracks ?? this.applyPreviewOverlay(committedTracks); - const command = new TracksSnapshotCommand(committedTracks, afterTracks); + const command = new TracksSnapshotCommand({ + before: committedTracks, + after: afterTracks, + }); this.editor.command.push({ command }); this.previewOverlay.clear(); this.previewTracks = null; @@ -840,7 +841,7 @@ export class TimelineManager { }): void { const shouldMute = elements.some(({ trackId, elementId }) => { const element = this.getElementByRef({ trackId, elementId }); - return element && canElementHaveAudio(element) && !element.muted; + return element && canElementHaveAudio(element) && !isElementMuted({ element }); }); const nextUpdates = elements.flatMap(({ trackId, elementId }) => { @@ -853,7 +854,7 @@ export class TimelineManager { { trackId, elementId, - patch: { muted: shouldMute }, + patch: { params: { muted: shouldMute } }, }, ]; }); diff --git a/apps/web/src/editor/panel-store.ts b/apps/web/src/editor/panel-store.ts index 2cb768a1..5a2bd0f4 100644 --- a/apps/web/src/editor/panel-store.ts +++ b/apps/web/src/editor/panel-store.ts @@ -14,7 +14,7 @@ export type PanelId = keyof PanelSizes; interface PanelState {
panels: PanelSizes;
- setPanel: (panel: PanelId, size: number) => void;
+ setPanel: (args: { panel: PanelId; size: number }) => void;
setPanels: (sizes: Partial<PanelSizes>) => void;
resetPanels: () => void;
}
@@ -23,7 +23,7 @@ export const usePanelStore = create<PanelState>()( persist(
(set) => ({
...PANEL_CONFIG,
- setPanel: (panel, size) =>
+ setPanel: ({ panel, size }) =>
set((state) => ({
panels: {
...state.panels,
diff --git a/apps/web/src/editor/use-editor.ts b/apps/web/src/editor/use-editor.ts index ce48e036..6380d470 100644 --- a/apps/web/src/editor/use-editor.ts +++ b/apps/web/src/editor/use-editor.ts @@ -1,7 +1,15 @@ import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; import { EditorCore } from "@/core"; -function isShallowEqual(a: unknown, b: unknown): boolean { +const SNAPSHOT_UNSET = Symbol("snapshotUnset"); + +function isShallowEqual({ + a, + b, +}: { + a: unknown; + b: unknown; +}): boolean { if (Object.is(a, b)) return true; if (!Array.isArray(a) || !Array.isArray(b)) return false; if (a.length !== b.length) return false; @@ -16,10 +24,7 @@ export function useEditor<T>( selector?: (editor: EditorCore) => T, ): EditorCore | T { const editor = useMemo(() => EditorCore.getInstance(), []); - const selectorRef = useRef(selector); - selectorRef.current = selector; - - const snapshotCacheRef = useRef<unknown>(undefined); + const snapshotCacheRef = useRef<T | typeof SNAPSHOT_UNSET>(SNAPSHOT_UNSET); const subscribeAll = useCallback( (onChange: () => void) => { @@ -43,18 +48,29 @@ export function useEditor<T>( [editor], ); - const getSnapshot = useCallback(() => { - const next = selectorRef.current ? selectorRef.current(editor) : editor; - if (isShallowEqual(snapshotCacheRef.current, next)) { + const getSnapshot = useCallback((): EditorCore | T => { + if (!selector) { + return editor; + } + + const next = selector(editor); + if ( + snapshotCacheRef.current !== SNAPSHOT_UNSET && + isShallowEqual({ + a: snapshotCacheRef.current, + b: next, + }) + ) { return snapshotCacheRef.current; } + snapshotCacheRef.current = next; return next; - }, [editor]); + }, [editor, selector]); return useSyncExternalStore( selector ? subscribeAll : subscribeNone, getSnapshot, getSnapshot, - ) as EditorCore | T; + ); } diff --git a/apps/web/src/effects/definitions/index.ts b/apps/web/src/effects/definitions/index.ts index edc8c5e2..511df042 100644 --- a/apps/web/src/effects/definitions/index.ts +++ b/apps/web/src/effects/definitions/index.ts @@ -8,6 +8,9 @@ export function registerDefaultEffects(): void { if (effectsRegistry.has(definition.type)) { continue; } - effectsRegistry.register(definition.type, definition); + effectsRegistry.register({ + key: definition.type, + definition, + }); } } diff --git a/apps/web/src/fps/utils.ts b/apps/web/src/fps/utils.ts index e30e263a..c2a57fbb 100644 --- a/apps/web/src/fps/utils.ts +++ b/apps/web/src/fps/utils.ts @@ -22,7 +22,13 @@ export function frameRateToFloat(rate: FrameRate): number { return rate.numerator / rate.denominator; } -export function frameRatesEqual(a: FrameRate, b: FrameRate): boolean { +export function frameRatesEqual({ + a, + b, +}: { + a: FrameRate; + b: FrameRate; +}): boolean { return a.numerator === b.numerator && a.denominator === b.denominator; } @@ -38,14 +44,17 @@ export function floatToFrameRate(fps: number): FrameRate { const ARBITRARY_DENOMINATOR = 1_000_000; const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR); - const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR); + const divisor = gcd({ + left: scaledNumerator, + right: ARBITRARY_DENOMINATOR, + }); return { numerator: scaledNumerator / divisor, denominator: ARBITRARY_DENOMINATOR / divisor, }; } -function gcd(left: number, right: number): number { +function gcd({ left, right }: { left: number; right: number }): number { let a = Math.abs(left); let b = Math.abs(right); while (b !== 0) { diff --git a/apps/web/src/graphics/components/graphic-tab.tsx b/apps/web/src/graphics/components/graphic-tab.tsx index e0ae0236..31c4a86d 100644 --- a/apps/web/src/graphics/components/graphic-tab.tsx +++ b/apps/web/src/graphics/components/graphic-tab.tsx @@ -8,11 +8,7 @@ import { } from "@/components/editor/panels/properties/hooks/use-keyframed-param-property"; import type { ParamDefinition, ParamValues } from "@/params"; import type { GraphicElement } from "@/timeline"; -import { - graphicsRegistry, - registerDefaultGraphics, - resolveGraphicElementParamsAtTime, -} from "@/graphics"; +import { graphicsRegistry, registerDefaultGraphics, resolveGraphicElementParamsAtTime } from "@/graphics"; import { useElementPreview } from "@/timeline/hooks/use-element-preview"; import { useEditor } from "@/editor/use-editor"; import { @@ -27,6 +23,7 @@ import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; import { cn } from "@/utils/ui"; +import type { MediaTime } from "@/wasm"; registerDefaultGraphics(); @@ -200,10 +197,11 @@ function AnimatedGraphicParamField({ isPlayheadWithinElementRange, resolvedParams, }: { + key?: string; param: ParamDefinition; trackId: string; element: GraphicElement; - localTime: number; + localTime: MediaTime; isPlayheadWithinElementRange: boolean; resolvedParams: ParamValues; }) { diff --git a/apps/web/src/graphics/definitions/index.ts b/apps/web/src/graphics/definitions/index.ts index 6fd9e446..846a9983 100644 --- a/apps/web/src/graphics/definitions/index.ts +++ b/apps/web/src/graphics/definitions/index.ts @@ -16,7 +16,10 @@ export function registerDefaultGraphics(): void { if (graphicsRegistry.has(definition.id)) {
continue;
}
- graphicsRegistry.register(definition.id, definition);
+ graphicsRegistry.register({
+ key: definition.id,
+ definition,
+ });
}
}
diff --git a/apps/web/src/graphics/index.ts b/apps/web/src/graphics/index.ts index 7f5ac08b..7b8d905e 100644 --- a/apps/web/src/graphics/index.ts +++ b/apps/web/src/graphics/index.ts @@ -60,10 +60,13 @@ export function buildDefaultGraphicInstance({ }; } -export function resolveGraphicParams( - definition: GraphicDefinition, - params?: ParamValues, -): ParamValues { +export function resolveGraphicParams({ + definition, + params, +}: { + definition: GraphicDefinition; + params?: ParamValues; +}): ParamValues { return { ...buildDefaultParamValues(definition.params), ...(params ?? {}), @@ -85,7 +88,10 @@ export function resolveGraphicElementParamsAtTime({ definitionId: element.definitionId, }); return resolveGraphicParamsAtTime({ - params: resolveGraphicParams(definition, element.params), + params: resolveGraphicParams({ + definition, + params: element.params, + }), definitions: definition.params, animations: element.animations, localTime, @@ -102,7 +108,7 @@ export function buildGraphicPreviewUrl({ size?: number; }): string { const definition = getGraphicDefinition({ definitionId }); - const resolvedParams = resolveGraphicParams(definition, params); + const resolvedParams = resolveGraphicParams({ definition, params }); const cacheKey = JSON.stringify({ definitionId, resolvedParams, size }); const cachedUrl = graphicPreviewUrlCache.get(cacheKey); if (cachedUrl) { diff --git a/apps/web/src/hooks/use-committed-ref.ts b/apps/web/src/hooks/use-committed-ref.ts new file mode 100644 index 00000000..ff064985 --- /dev/null +++ b/apps/web/src/hooks/use-committed-ref.ts @@ -0,0 +1,11 @@ +import { useLayoutEffect, useRef } from "react"; + +export function useCommittedRef<T>(value: T) { + const ref = useRef(value); + + useLayoutEffect(() => { + ref.current = value; + }, [value]); + + return ref; +} diff --git a/apps/web/src/hooks/use-focus-lock.ts b/apps/web/src/hooks/use-focus-lock.ts index 236d2623..86092aec 100644 --- a/apps/web/src/hooks/use-focus-lock.ts +++ b/apps/web/src/hooks/use-focus-lock.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
@@ -37,8 +38,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({ allowSelector?: string;
}) {
const containerRef = useRef<T>(null);
- const onDismissRef = useRef(onDismiss);
- onDismissRef.current = onDismiss;
+ const onDismissRef = useCommittedRef(onDismiss);
useEffect(() => {
if (!isActive) return;
@@ -53,10 +53,13 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({ const handleOutsidePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
- if (container.contains(event.target as Node)) return;
+ const target = event.target;
+ if (target instanceof Node && container.contains(target)) return;
- const target = event.target as Element | null;
- const isAllowedTarget = allowSelector && target?.closest(allowSelector);
+ const isAllowedTarget =
+ allowSelector &&
+ target instanceof Element &&
+ target.closest(allowSelector);
if (isAllowedTarget) return;
onDismissRef.current();
@@ -73,7 +76,7 @@ export function useFocusLock<T extends HTMLElement = HTMLElement>({ container.removeAttribute(DATA_ATTR);
focusLockStyle.remove();
};
- }, [isActive, cursor, allowSelector]);
+ }, [isActive, cursor, allowSelector, onDismissRef]);
return { containerRef };
}
diff --git a/apps/web/src/masks/__tests__/snap.test.ts b/apps/web/src/masks/__tests__/snap.test.ts index 2f33615f..a5d0ceb3 100644 --- a/apps/web/src/masks/__tests__/snap.test.ts +++ b/apps/web/src/masks/__tests__/snap.test.ts @@ -1,22 +1,22 @@ import { describe, expect, test } from "bun:test"; import { - findClosestPointOnCustomMaskSegment, - getCustomMaskClosedStateAfterPointRemoval, - insertPointIntoCustomMaskSegment, - removeCustomMaskPoints, -} from "@/masks/custom-path"; + findClosestPointOnFreeformSegment, + getFreeformPathClosedStateAfterPointRemoval, + insertPointIntoFreeformSegment, + removeFreeformPathPoints, +} from "@/masks/freeform/path"; import { - appendPointToCustomMask, - customMaskDefinition, - insertPointOnCustomMaskSegment, -} from "@/masks/definitions/custom"; -import { getSplitMaskStrokeSegment } from "@/masks/definitions/split"; -import { textMaskDefinition } from "@/masks/definitions/text"; + appendPointToFreeformPathMask, + freeformMaskDefinition, + insertPointOnFreeformSegment, +} from "@/masks/freeform/definition"; +import { getSplitMaskStrokeSegment } from "@/masks/builtin/definitions/split"; +import { textMaskDefinition } from "@/masks/builtin/definitions/text"; import { getMaskSnapGeometry } from "@/masks/geometry"; -import { snapMaskInteraction } from "@/masks/snap"; +import { snapBoxMaskInteraction, snapSplitMaskInteraction } from "@/masks/snap"; import type { ElementBounds } from "@/preview/element-bounds"; import type { - CustomMaskParams, + FreeformPathMaskParams, RectangleMaskParams, SplitMaskParams, TextMaskParams, @@ -100,9 +100,9 @@ function buildTextMaskParams( }; } -function buildCustomMaskParams( - overrides: Partial<CustomMaskParams> = {}, -): CustomMaskParams { +function buildFreeformPathMaskParams( + overrides: Partial<FreeformPathMaskParams> = {}, +): FreeformPathMaskParams { return { feather: 0, inverted: false, @@ -150,10 +150,10 @@ function buildCustomMaskParams( function sortSegment( segment: [{ x: number; y: number }, { x: number; y: number }], ): [{ x: number; y: number }, { x: number; y: number }] { - return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ - { x: number; y: number }, - { x: number; y: number }, - ]; + const [first, second] = segment; + return first.x < second.x || (first.x === second.x && first.y <= second.y) + ? [first, second] + : [second, first]; } describe("mask geometry", () => { @@ -230,8 +230,8 @@ describe("mask geometry", () => { describe("mask snapping", () => { test("snaps split mask movement using the shared position pipeline", () => { - const result = snapMaskInteraction({ - handleId: "position", + const result = snapSplitMaskInteraction({ + handleId: { kind: "position" }, startParams: buildSplitParams({ centerX: 0.03, centerY: -0.04, @@ -254,8 +254,8 @@ describe("mask snapping", () => { }); test("snaps box mask movement against element center and edges", () => { - const result = snapMaskInteraction({ - handleId: "position", + const result = snapBoxMaskInteraction({ + handleId: { kind: "position" }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ centerX: 0.29, @@ -275,8 +275,8 @@ describe("mask snapping", () => { }); test("snaps mask rotation through the shared rotation path", () => { - const result = snapMaskInteraction({ - handleId: "rotation", + const result = snapBoxMaskInteraction({ + handleId: { kind: "rotation" }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ rotation: 88, @@ -291,8 +291,8 @@ describe("mask snapping", () => { }); test("snaps edge resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "right", + const result = snapBoxMaskInteraction({ + handleId: { kind: "edge", side: "right" }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ width: 0.98, @@ -307,8 +307,8 @@ describe("mask snapping", () => { }); test("snaps corner resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "bottom-right", + const result = snapBoxMaskInteraction({ + handleId: { kind: "corner", corner: { x: "right", y: "bottom" } }, startParams: buildRectangleParams(), proposedParams: buildRectangleParams({ width: 0.99, @@ -324,13 +324,49 @@ describe("mask snapping", () => { expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); }); + test("snaps vertical edge resize for box masks", () => { + // bounds.height=100 → localCanvasSize.height/2=50 is the bottom snap target. + // height=0.2 → baseHeight=20 → aabbHalfH=10. + // At height=0.98 → proposedScaleY=4.9 → bottomEdge=0+10*4.9=49. + // |49-50|=1 < threshold(8) → snaps to height=0.2*5=1.0; line at position 50. + const result = snapBoxMaskInteraction({ + handleId: { kind: "edge", side: "bottom" }, + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ height: 0.98 }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.height).toBe(1); + expect(result.activeLines).toEqual([{ type: "horizontal", position: 50 }]); + }); + + test("snaps uniform scale handle for box masks", () => { + // bounds.width=200 → localCanvasSize.width/2=100 is the right snap target. + // width=0.4, scale=1 → aabbHalfW = (0.4*200)/2 * 1 = 40. + // At scale=2.48 → rightEdge=0+40*2.48=99.2; |99.2-100|=0.8 < threshold(8) + // → snaps to scale=1*(100/40)=2.5; line at position 100. + const result = snapBoxMaskInteraction({ + handleId: { kind: "scale" }, + startParams: buildRectangleParams({ scale: 1 }), + proposedParams: buildRectangleParams({ scale: 2.48 }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.scale).toBe(2.5); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); + test("snaps text mask movement using intrinsic text bounds", () => { const params = buildTextMaskParams({ centerX: 0.03, centerY: -0.04, }); const result = textMaskDefinition.interaction.snap?.({ - handleId: "position", + handleId: { kind: "position" }, startParams: params, proposedParams: params, bounds, @@ -347,12 +383,12 @@ describe("mask snapping", () => { }); test("snaps custom mask movement using path geometry bounds", () => { - const params = buildCustomMaskParams({ + const params = buildFreeformPathMaskParams({ centerX: 0.03, centerY: -0.04, }); - const result = customMaskDefinition.interaction.snap?.({ - handleId: "position", + const result = freeformMaskDefinition.interaction.snap?.({ + handleId: { kind: "position" }, startParams: params, proposedParams: params, bounds, @@ -381,11 +417,11 @@ describe("mask snapping", () => { describe("custom mask creation", () => { test("anchors the first point at the click position", () => { - const params = buildCustomMaskParams({ + const params = buildFreeformPathMaskParams({ path: [], closed: false, }); - const next = appendPointToCustomMask({ + const next = appendPointToFreeformPathMask({ params, canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 }, bounds, @@ -401,8 +437,8 @@ describe("custom mask creation", () => { describe("custom mask point deletion", () => { test("removes the selected points by id", () => { - const points = buildCustomMaskParams().path; - const nextPoints = removeCustomMaskPoints({ + const points = buildFreeformPathMaskParams().path; + const nextPoints = removeFreeformPathPoints({ points, pointIds: ["b"], }); @@ -411,14 +447,14 @@ describe("custom mask point deletion", () => { }); test("reopens a closed path once fewer than three points remain", () => { - const points = buildCustomMaskParams().path; - const nextPoints = removeCustomMaskPoints({ + const points = buildFreeformPathMaskParams().path; + const nextPoints = removeFreeformPathPoints({ points, pointIds: ["c"], }); expect( - getCustomMaskClosedStateAfterPointRemoval({ + getFreeformPathClosedStateAfterPointRemoval({ wasClosed: true, remainingPointCount: nextPoints.length, }), @@ -428,9 +464,9 @@ describe("custom mask point deletion", () => { describe("custom mask point insertion", () => { test("finds the closest point on the clicked segment", () => { - const params = buildCustomMaskParams(); + const params = buildFreeformPathMaskParams(); const points = params.path; - const closestPoint = findClosestPointOnCustomMaskSegment({ + const closestPoint = findClosestPointOnFreeformSegment({ points, segmentIndex: 0, canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, @@ -449,8 +485,8 @@ describe("custom mask point insertion", () => { }); test("splits a segment into two segments at the insertion point", () => { - const points = buildCustomMaskParams().path; - const nextPoints = insertPointIntoCustomMaskSegment({ + const points = buildFreeformPathMaskParams().path; + const nextPoints = insertPointIntoFreeformSegment({ points, segmentIndex: 0, pointId: "new", @@ -471,8 +507,8 @@ describe("custom mask point insertion", () => { }); test("builds updated custom mask params for a clicked segment", () => { - const result = insertPointOnCustomMaskSegment({ - params: buildCustomMaskParams(), + const result = insertPointOnFreeformSegment({ + params: buildFreeformPathMaskParams(), segmentIndex: 0, canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, bounds, diff --git a/apps/web/src/masks/definitions/box-like.ts b/apps/web/src/masks/builtin/box-like.ts index 7db51df7..37706755 100644 --- a/apps/web/src/masks/definitions/box-like.ts +++ b/apps/web/src/masks/builtin/box-like.ts @@ -16,7 +16,7 @@ import { getBoxMaskHandlePositions, getBoxMaskOverlays, } from "@/masks/handle-positions"; -import { snapMaskInteraction } from "@/masks/snap"; +import { snapBoxMaskInteraction } from "@/masks/snap"; const PERCENTAGE_DISPLAY: Pick< NumberParamDefinition, @@ -198,7 +198,7 @@ export function buildBoxMaskInteraction({ }; }, snap(args) { - return snapMaskInteraction(args); + return snapBoxMaskInteraction(args); }, }; } @@ -210,20 +210,20 @@ export function computeBoxMaskParamUpdate({ deltaY, bounds, }: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> { - if (handleId === "position") { + if (handleId.kind === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, centerY: startParams.centerY + deltaY / bounds.height, }; } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; const newRotation = (startParams.rotation + currentAngle) % 360; return { rotation: newRotation < 0 ? newRotation + 360 : newRotation }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -237,8 +237,11 @@ export function computeBoxMaskParamUpdate({ const halfWidth = startParams.width * bounds.width; const halfHeight = startParams.height * bounds.height; - if (handleId === "right" || handleId === "left") { - const sign = handleId === "right" ? 1 : -1; + if ( + handleId.kind === "edge" && + (handleId.side === "right" || handleId.side === "left") + ) { + const sign = handleId.side === "right" ? 1 : -1; return { width: Math.max( MIN_MASK_DIMENSION, @@ -247,8 +250,11 @@ export function computeBoxMaskParamUpdate({ }; } - if (handleId === "bottom" || handleId === "top") { - const sign = handleId === "bottom" ? 1 : -1; + if ( + handleId.kind === "edge" && + (handleId.side === "bottom" || handleId.side === "top") + ) { + const sign = handleId.side === "bottom" ? 1 : -1; return { height: Math.max( MIN_MASK_DIMENSION, @@ -257,14 +263,9 @@ export function computeBoxMaskParamUpdate({ }; } - if ( - handleId === "top-left" || - handleId === "top-right" || - handleId === "bottom-left" || - handleId === "bottom-right" - ) { - const signX = handleId.includes("right") ? 1 : -1; - const signY = handleId.includes("bottom") ? 1 : -1; + if (handleId.kind === "corner") { + const signX = handleId.corner.x === "right" ? 1 : -1; + const signY = handleId.corner.y === "bottom" ? 1 : -1; const distance = Math.sqrt( (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2, ); @@ -276,7 +277,7 @@ export function computeBoxMaskParamUpdate({ }; } - if (handleId === "scale") { + if (handleId.kind === "scale") { const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2); const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2); const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1; diff --git a/apps/web/src/masks/definitions/cinematic-bars.ts b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts index 36b31328..0e23fd58 100644 --- a/apps/web/src/masks/definitions/cinematic-bars.ts +++ b/apps/web/src/masks/builtin/definitions/cinematic-bars.ts @@ -10,7 +10,7 @@ import { getDefaultBaseMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function getDefaultCinematicBarsMaskParams({ elementSize, @@ -71,7 +71,7 @@ function buildBandPath({ return path; } -export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = { type: "cinematic-bars", name: "Cinematic Bars", features: { @@ -94,42 +94,51 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const centerX = width / 2 + params.centerX * width; - const centerY = height / 2 + params.centerY * height; - const maskWidth = Math.max(params.width * width, width); - const maskHeight = Math.max(params.height, 0.01) * height; - const rotationRad = (params.rotation * Math.PI) / 180; + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const centerX = width / 2 + params.centerX * width; + const centerY = height / 2 + params.centerY * height; + const maskWidth = Math.max(params.width * width, width); + const maskHeight = Math.max(params.height, 0.01) * height; + const rotationRad = (params.rotation * Math.PI) / 180; - return buildBandPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + return buildBandPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const centerX = width / 2 + params.centerX * width; - const centerY = height / 2 + params.centerY * height; - const rotationRad = (params.rotation * Math.PI) / 180; - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const centerX = width / 2 + params.centerX * width; + const centerY = height / 2 + params.centerY * height; + const rotationRad = (params.rotation * Math.PI) / 180; + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); - return buildBandPath({ - centerX, - centerY, - halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1), - halfHeight: Math.max( - (Math.max(params.height, 0.01) * height) / 2 + offset, - 1, - ), - rotationRad, - }); + return buildBandPath({ + centerX, + centerY, + halfWidth: Math.max( + Math.max(params.width * width, width) / 2 + offset, + 1, + ), + halfHeight: Math.max( + (Math.max(params.height, 0.01) * height) / 2 + offset, + 1, + ), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/diamond.ts b/apps/web/src/masks/builtin/definitions/diamond.ts index 037aee70..1effb276 100644 --- a/apps/web/src/masks/definitions/diamond.ts +++ b/apps/web/src/masks/builtin/definitions/diamond.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function buildDiamondPath({ centerX, @@ -45,7 +45,7 @@ function buildDiamondPath({ return path; } -export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const diamondMaskDefinition: MaskDefinition<"diamond"> = { type: "diamond", name: "Diamond", features: { @@ -68,33 +68,39 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildDiamondPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildDiamondPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildDiamondPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildDiamondPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/builtin/definitions/ellipse.ts b/apps/web/src/masks/builtin/definitions/ellipse.ts new file mode 100644 index 00000000..688a2fa6 --- /dev/null +++ b/apps/web/src/masks/builtin/definitions/ellipse.ts @@ -0,0 +1,81 @@ +import type { MaskDefinition } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, +} from "../box-like"; + +export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = { + type: "ellipse", + name: "Ellipse", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + buildOverlayPath({ width, height }) { + const rx = Math.max((width - 1) / 2, 0); + const ry = Math.max((height - 1) / 2, 0); + const cx = width / 2; + const cy = height / 2; + return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`; + }, + }), + buildDefault(context) { + return { + type: "ellipse", + params: getDefaultSquareMaskParams(context), + }; + }, + computeParamUpdate: computeBoxMaskParamUpdate, + renderer: { + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const path = new Path2D(); + path.ellipse( + centerX, + centerY, + maskWidth / 2, + maskHeight / 2, + rotationRad, + 0, + Math.PI * 2, + ); + return path; + }, + }, + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + const path = new Path2D(); + path.ellipse( + centerX, + centerY, + Math.max(1, maskWidth / 2 + offset), + Math.max(1, maskHeight / 2 + offset), + rotationRad, + 0, + Math.PI * 2, + ); + return path; + }, + }, + }, +}; diff --git a/apps/web/src/masks/definitions/heart.ts b/apps/web/src/masks/builtin/definitions/heart.ts index d15c09e6..d1af5eb2 100644 --- a/apps/web/src/masks/definitions/heart.ts +++ b/apps/web/src/masks/builtin/definitions/heart.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function buildHeartPath({ centerX, @@ -78,7 +78,7 @@ function buildHeartPath({ return path; } -export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const heartMaskDefinition: MaskDefinition<"heart"> = { type: "heart", name: "Heart", features: { @@ -110,33 +110,39 @@ export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildHeartPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildHeartPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildHeartPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildHeartPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/index.ts b/apps/web/src/masks/builtin/definitions/index.ts index 9cee1020..5deb4e3f 100644 --- a/apps/web/src/masks/definitions/index.ts +++ b/apps/web/src/masks/builtin/definitions/index.ts @@ -1,7 +1,9 @@ -import type { BaseMaskParams, MaskDefinition } from "@/masks/types"; -import { masksRegistry, type MaskIconProps } from "../registry"; +import { + masksRegistry, + type MaskDefinitionForRegistration, + type MaskIconProps, +} from "../../registry"; import { cinematicBarsMaskDefinition } from "./cinematic-bars"; -import { customMaskDefinition } from "./custom"; import { diamondMaskDefinition } from "./diamond"; import { ellipseMaskDefinition } from "./ellipse"; import { heartMaskDefinition } from "./heart"; @@ -9,6 +11,7 @@ import { rectangleMaskDefinition } from "./rectangle"; import { splitMaskDefinition } from "./split"; import { starMaskDefinition } from "./star"; import { textMaskDefinition } from "./text"; +import { freeformMaskDefinition } from "../../freeform/definition"; import { MinusSignIcon, PanelRightDashedIcon, @@ -18,13 +21,14 @@ import { DiamondIcon, StarsIcon, TextFontIcon, + PenToolAddIcon, } from "@hugeicons/core-free-icons"; -function registerDefaultMask<TParams extends BaseMaskParams>({ +function registerDefaultMask({ definition, icon, }: { - definition: MaskDefinition<TParams>; + definition: MaskDefinitionForRegistration; icon: MaskIconProps; }) { if (masksRegistry.has(definition.type)) { @@ -68,7 +72,7 @@ export function registerDefaultMasks(): void { icon: { icon: TextFontIcon }, }); registerDefaultMask({ - definition: customMaskDefinition, - icon: { icon: SquareIcon }, + definition: freeformMaskDefinition, + icon: { icon: PenToolAddIcon }, }); } diff --git a/apps/web/src/masks/definitions/rectangle.ts b/apps/web/src/masks/builtin/definitions/rectangle.ts index 7bf1a7fd..4dd38154 100644 --- a/apps/web/src/masks/definitions/rectangle.ts +++ b/apps/web/src/masks/builtin/definitions/rectangle.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; function buildRectanglePath({ centerX, @@ -45,7 +45,7 @@ function buildRectanglePath({ return path; } -export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = { type: "rectangle", name: "Rectangle", features: { @@ -65,33 +65,39 @@ export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildRectanglePath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildRectanglePath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildRectanglePath({ - centerX, - centerY, - halfWidth: Math.max(1, maskWidth / 2 + offset), - halfHeight: Math.max(1, maskHeight / 2 + offset), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildRectanglePath({ + centerX, + centerY, + halfWidth: Math.max(1, maskWidth / 2 + offset), + halfHeight: Math.max(1, maskHeight / 2 + offset), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/split.ts b/apps/web/src/masks/builtin/definitions/split.ts index d50a2571..13dd1c09 100644 --- a/apps/web/src/masks/definitions/split.ts +++ b/apps/web/src/masks/builtin/definitions/split.ts @@ -1,15 +1,15 @@ -import { computeFeatherUpdate } from "../param-update"; +import { computeFeatherUpdate } from "@/masks/param-update"; import type { MaskDefinition, MaskParamUpdateArgs, SplitMaskParams, } from "@/masks/types"; -import { halfPlaneSign, lineEdgeIntersection } from "../utils"; +import { halfPlaneSign, lineEdgeIntersection } from "@/masks/utils"; import { getLineMaskHandlePositions, getLineMaskOverlay, } from "@/masks/handle-positions"; -import { snapMaskInteraction } from "@/masks/snap"; +import { snapSplitMaskInteraction } from "@/masks/snap"; // cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped // to exactly 0 to prevent opposite-sign float noise on canvas corners that lie @@ -53,10 +53,13 @@ function splitLineGeometry({ return { normalX, normalY, lineX, lineY }; } -function pointsEqual( - a: { x: number; y: number }, - b: { x: number; y: number }, -): boolean { +function pointsEqual({ + a, + b, +}: { + a: { x: number; y: number }; + b: { x: number; y: number }; +}): boolean { return ( Math.abs(a.x - b.x) <= INTERSECTION_EPSILON && Math.abs(a.y - b.y) <= INTERSECTION_EPSILON @@ -68,11 +71,11 @@ export function getSplitMaskStrokeSegment({ width, height, }: { - resolvedParams: unknown; + resolvedParams: SplitMaskParams; width: number; height: number; }): [{ x: number; y: number }, { x: number; y: number }] | null { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; + const { centerX, centerY, rotation } = resolvedParams; const { normalX, normalY, lineX, lineY } = splitLineGeometry({ centerX, centerY, @@ -101,7 +104,7 @@ export function getSplitMaskStrokeSegment({ y2, }); - if (!hit || intersections.some((point) => pointsEqual(point, hit))) { + if (!hit || intersections.some((point) => pointsEqual({ a: point, b: hit }))) { continue; } @@ -125,7 +128,7 @@ function computeSplitMaskParamUpdate({ bounds, canvasSize, }: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> { - if (handleId === "position") { + if (handleId.kind === "position") { const rawX = startParams.centerX + deltaX / bounds.width; const rawY = startParams.centerY + deltaY / bounds.height; @@ -140,7 +143,7 @@ function computeSplitMaskParamUpdate({ }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -151,7 +154,7 @@ function computeSplitMaskParamUpdate({ }); } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotY = bounds.cy + startParams.centerY * bounds.height; const startAngle = @@ -177,7 +180,7 @@ function computeSplitMaskParamUpdate({ return {}; } -export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { +export const splitMaskDefinition: MaskDefinition<"split"> = { type: "split", name: "Split", features: { @@ -213,7 +216,7 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { }; }, snap(args) { - return snapMaskInteraction(args); + return snapSplitMaskInteraction(args); }, }, buildDefault() { @@ -264,119 +267,131 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { }, ], renderer: { - renderMaskHandlesFeather: true, - renderMask({ resolvedParams, ctx, width, height, feather }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); + body: { + kind: "drawWithFeather", + drawWithFeather({ resolvedParams, ctx, width, height, feather }) { + const { centerX, centerY, rotation } = resolvedParams; + const { normalX, normalY, lineX, lineY } = splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, + }); - // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. - const featherHalf = feather / 2; - const gradient = ctx.createLinearGradient( - lineX - normalX * featherHalf, - lineY - normalY * featherHalf, - lineX + normalX * featherHalf, - lineY + normalY * featherHalf, - ); - gradient.addColorStop(0, "rgba(255,255,255,0)"); - gradient.addColorStop(1, "white"); + // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. + const featherHalf = feather / 2; + const gradient = ctx.createLinearGradient( + lineX - normalX * featherHalf, + lineY - normalY * featherHalf, + lineX + normalX * featherHalf, + lineY + normalY * featherHalf, + ); + gradient.addColorStop(0, "rgba(255,255,255,0)"); + gradient.addColorStop(1, "white"); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, width, height); - }, + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + }, - buildPath({ resolvedParams, width, height }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); + opaqueFastPath: { + buildPath({ resolvedParams, width, height }) { + const { centerX, centerY, rotation } = resolvedParams; + const { normalX, normalY, lineX, lineY } = splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, + }); - const edges: [number, number, number, number][] = [ - [0, 0, width, 0], - [width, 0, width, height], - [width, height, 0, height], - [0, height, 0, 0], - ]; + const edges: [number, number, number, number][] = [ + [0, 0, width, 0], + [width, 0, width, height], + [width, height, 0, height], + [0, height, 0, 0], + ]; - const isInsideHalfPlane = (x: number, y: number) => - halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; + const isInsideHalfPlane = ({ + x, + y, + }: { + x: number; + y: number; + }) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; - const vertices: [number, number][] = []; - for (const [x1, y1, x2, y2] of edges) { - const isVertex1Inside = isInsideHalfPlane(x1, y1); - const isVertex2Inside = isInsideHalfPlane(x2, y2); + const vertices: [number, number][] = []; + for (const [x1, y1, x2, y2] of edges) { + const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 }); + const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 }); - if (isVertex1Inside && isVertex2Inside) { - vertices.push([x2, y2]); - } else if (isVertex1Inside && !isVertex2Inside) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - if (hit) vertices.push([hit.x, hit.y]); - } else if (!isVertex1Inside && isVertex2Inside) { - const hit = lineEdgeIntersection({ - lineX, - lineY, - normalX, - normalY, - x1, - y1, - x2, - y2, - }); - if (hit) { - vertices.push([hit.x, hit.y]); - vertices.push([x2, y2]); + if (isVertex1Inside && isVertex2Inside) { + vertices.push([x2, y2]); + } else if (isVertex1Inside && !isVertex2Inside) { + const hit = lineEdgeIntersection({ + lineX, + lineY, + normalX, + normalY, + x1, + y1, + x2, + y2, + }); + if (hit) vertices.push([hit.x, hit.y]); + } else if (!isVertex1Inside && isVertex2Inside) { + const hit = lineEdgeIntersection({ + lineX, + lineY, + normalX, + normalY, + x1, + y1, + x2, + y2, + }); + if (hit) { + vertices.push([hit.x, hit.y]); + vertices.push([x2, y2]); + } + } } - } - } - if ( - vertices.length < 3 || - polygonArea({ vertices }) < MIN_POLYGON_AREA_PX - ) { - return new Path2D(); - } + if ( + vertices.length < 3 || + polygonArea({ vertices }) < MIN_POLYGON_AREA_PX + ) { + return new Path2D(); + } - const path = new Path2D(); - path.moveTo(vertices[0][0], vertices[0][1]); - for (let i = 1; i < vertices.length; i++) { - path.lineTo(vertices[i][0], vertices[i][1]); - } - path.closePath(); - return path; + const path = new Path2D(); + path.moveTo(vertices[0][0], vertices[0][1]); + for (let i = 1; i < vertices.length; i++) { + path.lineTo(vertices[i][0], vertices[i][1]); + } + path.closePath(); + return path; + }, + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const segment = getSplitMaskStrokeSegment({ - resolvedParams, - width, - height, - }); - const path = new Path2D(); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const segment = getSplitMaskStrokeSegment({ + resolvedParams, + width, + height, + }); + const path = new Path2D(); - if (!segment) { - return path; - } + if (!segment) { + return path; + } - path.moveTo(segment[0].x, segment[0].y); - path.lineTo(segment[1].x, segment[1].y); - return path; + path.moveTo(segment[0].x, segment[0].y); + path.lineTo(segment[1].x, segment[1].y); + return path; + }, }, }, }; diff --git a/apps/web/src/masks/definitions/star.ts b/apps/web/src/masks/builtin/definitions/star.ts index a7f8819c..62e9cbc4 100644 --- a/apps/web/src/masks/definitions/star.ts +++ b/apps/web/src/masks/builtin/definitions/star.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -7,7 +7,7 @@ import { getDefaultSquareMaskParams, getStrokeOffset, rotatePoint, -} from "./box-like"; +} from "../box-like"; const STAR_INNER_RADIUS_RATIO = 0.45; const STAR_VERTEX_COUNT = 10; @@ -85,7 +85,7 @@ function buildOverlayStarPath({ return `${segments.join(" ")} Z`; } -export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { +export const starMaskDefinition: MaskDefinition<"star"> = { type: "star", name: "Star", features: { @@ -108,33 +108,39 @@ export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildStarPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildStarPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildStarPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildStarPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/text.ts b/apps/web/src/masks/builtin/definitions/text.ts index c285f1c8..db2af7e0 100644 --- a/apps/web/src/masks/definitions/text.ts +++ b/apps/web/src/masks/builtin/definitions/text.ts @@ -4,6 +4,7 @@ import type { MaskParamUpdateArgs, TextMask, TextMaskParams, + MaskHandleId, } from "@/masks/types"; import { DEFAULTS } from "@/timeline/defaults"; import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography"; @@ -35,7 +36,7 @@ const PERCENTAGE_DISPLAY = { step: 1, } as const; -const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign; +const TEXT_MASK_ALIGNMENT = "center"; const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [ { @@ -60,7 +61,7 @@ const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [ key: "fontSize", label: "Size", type: "number", - default: DEFAULTS.text.element.fontSize, + default: 15, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE, step: 1, @@ -124,9 +125,9 @@ function measureTextMask({ function getScalePreferredEdges({ handleId, }: { - handleId: string; + handleId: MaskHandleId; }): ScaleEdgePreference | undefined { - if (handleId !== "scale") { + if (handleId.kind !== "scale") { return undefined; } @@ -145,7 +146,7 @@ function computeTextMaskParamUpdate({ startCanvasY, bounds, }: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> { - if (handleId === "position") { + if (handleId.kind === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, centerY: startParams.centerY + deltaY / bounds.height, @@ -155,7 +156,7 @@ function computeTextMaskParamUpdate({ const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotY = bounds.cy + startParams.centerY * bounds.height; - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const startAngle = (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / Math.PI; @@ -176,7 +177,7 @@ function computeTextMaskParamUpdate({ }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -187,7 +188,7 @@ function computeTextMaskParamUpdate({ }); } - if (handleId === "scale") { + if (handleId.kind === "scale") { const startDistance = Math.hypot( startCanvasX - pivotX, startCanvasY - pivotY, @@ -205,7 +206,7 @@ function computeTextMaskParamUpdate({ return {}; } -export const textMaskDefinition: MaskDefinition<TextMaskParams> = { +export const textMaskDefinition: MaskDefinition<"text"> = { type: "text", name: "Text", features: { @@ -269,7 +270,7 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { y: proposedParams.centerY * bounds.height, }; - if (handleId === "position") { + if (handleId.kind === "position") { const { snappedPosition, activeLines } = snapPosition({ proposedPosition: position, canvasSize: bounds, @@ -297,7 +298,7 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { }; } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const { snappedRotation } = snapRotation({ proposedRotation: proposedParams.rotation, }); @@ -310,7 +311,7 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { }; } - if (handleId === "scale") { + if (handleId.kind === "scale") { const { snappedScale, activeLines } = snapScale({ proposedScale: proposedParams.scale, position, @@ -351,11 +352,11 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { strokeWidth: 0, strokeAlign: "center", content: "Mask", - fontSize: DEFAULTS.text.element.fontSize, - fontFamily: DEFAULTS.text.element.fontFamily, - fontWeight: DEFAULTS.text.element.fontWeight, - fontStyle: DEFAULTS.text.element.fontStyle, - textDecoration: DEFAULTS.text.element.textDecoration, + fontSize: 15, + fontFamily: "Arial", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", letterSpacing: DEFAULTS.text.letterSpacing, lineHeight: DEFAULTS.text.lineHeight, centerX: 0, @@ -370,69 +371,75 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = { return params.content.trim().length > 0; }, renderer: { - renderMask({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as TextMaskParams; - const { layout } = measureTextMask({ params, height }); + body: { + kind: "drawOpaque", + drawOpaque({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + const { layout } = measureTextMask({ params, height }); - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } - drawMeasuredTextLayout({ - ctx, - layout, - textColor: "#ffffff", - background: null, - }); - ctx.restore(); - }, - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as TextMaskParams; - const { layout } = measureTextMask({ params, height }); - - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } - - strokeMeasuredTextLayout({ - ctx, - layout, - strokeColor: params.strokeColor, - strokeWidth: params.strokeWidth, - }); - - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; + ctx.save(); + ctx.translate( + width / 2 + params.centerX * width, + height / 2 + params.centerY * height, + ); + ctx.scale(params.scale, params.scale); + if (params.rotation) { + ctx.rotate((params.rotation * Math.PI) / 180); + } drawMeasuredTextLayout({ ctx, layout, textColor: "#ffffff", background: null, }); - } + ctx.restore(); + }, + }, + stroke: { + kind: "renderStroke", + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + const { layout } = measureTextMask({ params, height }); - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - drawMeasuredTextLayout({ + ctx.save(); + ctx.translate( + width / 2 + params.centerX * width, + height / 2 + params.centerY * height, + ); + ctx.scale(params.scale, params.scale); + if (params.rotation) { + ctx.rotate((params.rotation * Math.PI) / 180); + } + + strokeMeasuredTextLayout({ ctx, layout, - textColor: "#ffffff", - background: null, + strokeColor: params.strokeColor, + strokeWidth: params.strokeWidth, }); - } - ctx.restore(); + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + ctx.restore(); + }, }, }, }; diff --git a/apps/web/src/masks/components/masks-tab.tsx b/apps/web/src/masks/components/masks-tab.tsx index 97fba3ab..d6729d6d 100644 --- a/apps/web/src/masks/components/masks-tab.tsx +++ b/apps/web/src/masks/components/masks-tab.tsx @@ -3,7 +3,11 @@ import type { MaskableElement } from "@/timeline"; import type { Mask, MaskType, TextMask } from "@/masks/types"; import type { NumberParamDefinition, SelectParamDefinition } from "@/params"; -import { masksRegistry, buildDefaultMaskInstance } from "@/masks"; +import { + buildDefaultMaskInstance, + getMaskDefinition, + getMaskDefinitionsForMenu, +} from "@/masks"; import { useEditor } from "@/editor/use-editor"; import { useElementPreview } from "@/timeline/hooks/use-element-preview"; import { useMenuPreview } from "@/editor/use-menu-preview"; @@ -85,12 +89,43 @@ type PreviewParamHandler = ( key: string, ) => (value: number | string | boolean) => void; -type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>; +type RegisteredMaskDefinition = ReturnType<typeof getMaskDefinition>; function isTextMask(mask: Mask): mask is TextMask { return mask.type === "text"; } +function withPreviewedMaskParam({ + mask, + key, + value, +}: { + mask: Mask; + key: string; + value: number | string | boolean; +}): Mask { + switch (mask.type) { + case "split": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "cinematic-bars": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "rectangle": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "ellipse": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "heart": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "diamond": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "star": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "text": + return { ...mask, params: { ...mask.params, [key]: value } }; + case "freeform": + return { ...mask, params: { ...mask.params, [key]: value } }; + } +} + export function MasksTab({ element, trackId }: MasksTabProps) { const editor = useEditor(); const { renderElement, previewUpdates, commit } = @@ -99,7 +134,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) { elementId: element.id, fallback: element, }); - const maskDefs = masksRegistry.getAll(); + const maskDefs = getMaskDefinitionsForMenu(); const tracks = useEditor( (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, ); @@ -210,16 +245,10 @@ export function MasksTab({ element, trackId }: MasksTabProps) { const updatedMasks = renderMasks.map((existingMask, maskIndex) => maskIndex !== index ? existingMask - : { - ...existingMask, - params: { - ...existingMask.params, - [key]: value, - }, - }, + : withPreviewedMaskParam({ mask: existingMask, key, value }), ); - previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>); + previewUpdates({ masks: updatedMasks }); }; return ( @@ -301,7 +330,7 @@ function MaskItem({ onCommit, }: MaskItemProps) { const editor = useEditor(); - const definition = masksRegistry.get(mask.type); + const definition = getMaskDefinition(mask.type); return ( <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}> diff --git a/apps/web/src/masks/definitions/ellipse.ts b/apps/web/src/masks/definitions/ellipse.ts deleted file mode 100644 index d0567817..00000000 --- a/apps/web/src/masks/definitions/ellipse.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; -import { - BOX_LIKE_MASK_PARAMS, - buildBoxMaskInteraction, - computeBoxMaskParamUpdate, - getBoxLikeGeometry, - getDefaultSquareMaskParams, - getStrokeOffset, -} from "./box-like"; - -export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = { - type: "ellipse", - name: "Ellipse", - features: { - hasPosition: true, - hasRotation: true, - sizeMode: "width-height", - }, - params: BOX_LIKE_MASK_PARAMS, - interaction: buildBoxMaskInteraction({ - sizeMode: "width-height", - buildOverlayPath({ width, height }) { - const rx = Math.max((width - 1) / 2, 0); - const ry = Math.max((height - 1) / 2, 0); - const cx = width / 2; - const cy = height / 2; - return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`; - }, - }), - buildDefault(context) { - return { - type: "ellipse", - params: getDefaultSquareMaskParams(context), - }; - }, - computeParamUpdate: computeBoxMaskParamUpdate, - renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const path = new Path2D(); - path.ellipse( - centerX, - centerY, - maskWidth / 2, - maskHeight / 2, - rotationRad, - 0, - Math.PI * 2, - ); - return path; - }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - const path = new Path2D(); - path.ellipse( - centerX, - centerY, - Math.max(1, maskWidth / 2 + offset), - Math.max(1, maskHeight / 2 + offset), - rotationRad, - 0, - Math.PI * 2, - ); - return path; - }, - }, -}; diff --git a/apps/web/src/masks/definitions/custom.ts b/apps/web/src/masks/freeform/definition.ts index cd6e2055..ae30110e 100644 --- a/apps/web/src/masks/definitions/custom.ts +++ b/apps/web/src/masks/freeform/definition.ts @@ -3,27 +3,26 @@ import type { ParamDefinition } from "@/params"; import { PEN_CURSOR } from "@/preview/components/cursors"; import type { ElementBounds } from "@/preview/element-bounds"; import type { - CustomMask, - CustomMaskParams, + FreeformPathMask, + FreeformPathMaskParams, MaskDefinition, MaskHandlePosition, MaskOverlay, MaskParamUpdateArgs, } from "@/masks/types"; import { - buildCustomMaskPath2D, - buildCustomMaskSvgPath, - customMaskCanvasPointToLocal, - findClosestPointOnCustomMaskSegment, - getCustomMaskCanvasSegments, - getCustomMaskCanvasGeometry, - getCustomMaskLocalBounds, - getCustomMaskSegmentCount, - insertPointIntoCustomMaskSegment, - parseCustomMaskHandleId, - recenterCustomMaskPath, - type CustomMaskPathPoint, -} from "@/masks/custom-path"; + buildFreeformPath2D, + buildFreeformSvgPath, + freeformCanvasPointToLocal, + findClosestPointOnFreeformSegment, + getFreeformCanvasSegments, + getFreeformCanvasGeometry, + getFreeformLocalBounds, + getFreeformSegmentCount, + insertPointIntoFreeformSegment, + recenterFreeformPath, + type FreeformPathPoint, +} from "@/masks/freeform/path"; import { getBoxMaskHandlePositions } from "@/masks/handle-positions"; import { computeFeatherUpdate } from "@/masks/param-update"; import { @@ -41,7 +40,7 @@ const PERCENTAGE_DISPLAY = { step: 1, } as const; -const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [ +const FREEFORM_PATH_MASK_PARAMS: ParamDefinition<keyof FreeformPathMaskParams & string>[] = [ { key: "centerX", label: "X", @@ -80,12 +79,12 @@ const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [ }, ]; -function getCustomMaskDisplayHandles({ +function getFreeformDisplayHandles({ params, displayScale, bounds, }: { - params: CustomMaskParams; + params: FreeformPathMaskParams; displayScale: number; bounds: ElementBounds; }): { @@ -93,7 +92,7 @@ function getCustomMaskDisplayHandles({ overlays: MaskOverlay[]; } { const points = params.path; - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX: params.centerX, centerY: params.centerY, @@ -109,7 +108,7 @@ function getCustomMaskDisplayHandles({ overlays.push({ id: "path", type: "canvas-path", - pathData: buildCustomMaskSvgPath({ + pathData: buildFreeformSvgPath({ points, centerX: params.centerX, centerY: params.centerY, @@ -125,7 +124,7 @@ function getCustomMaskDisplayHandles({ if (params.closed) { const segmentStrokeWidth = 12; overlays.push( - ...getCustomMaskCanvasSegments({ + ...getFreeformCanvasSegments({ points, centerX: params.centerX, centerY: params.centerY, @@ -133,12 +132,12 @@ function getCustomMaskDisplayHandles({ scale: params.scale, bounds, closed: true, - }).map((segment) => ({ + }).map((segment): MaskOverlay => ({ id: `segment:${segment.index}`, type: "canvas-path" as const, pathData: segment.pathData, coordinateSpace: "canvas" as const, - handleId: `segment:${segment.index}`, + handleId: { kind: "segment", index: segment.index }, cursor: PEN_CURSOR, strokeOpacity: 0, strokeWidth: segmentStrokeWidth, @@ -146,7 +145,7 @@ function getCustomMaskDisplayHandles({ ); } - const localBounds = getCustomMaskLocalBounds({ points, bounds }); + const localBounds = getFreeformLocalBounds({ points, bounds }); if (params.closed && localBounds) { handles.push( ...getBoxMaskHandlePositions({ @@ -166,7 +165,7 @@ function getCustomMaskDisplayHandles({ geometry.anchors.forEach((point) => { handles.push({ - id: `point:${point.id}:anchor`, + id: { kind: "anchor", pointId: point.id }, x: point.anchor.x, y: point.anchor.y, cursor: params.closed ? "move" : "pointer", @@ -180,19 +179,19 @@ function getCustomMaskDisplayHandles({ }; } -function updateCustomMaskPoint({ +function updateFreeformPathMaskPoint({ points, pointId, updater, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; pointId: string; - updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint; + updater: (point: FreeformPathPoint) => FreeformPathPoint; }) { return points.map((point) => (point.id === pointId ? updater(point) : point)); } -function computeCustomMaskParamUpdate({ +function computeFreeformParamUpdate({ handleId, startParams, deltaX, @@ -200,8 +199,8 @@ function computeCustomMaskParamUpdate({ startCanvasX, startCanvasY, bounds, -}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> { - if (handleId === "position") { +}: MaskParamUpdateArgs<FreeformPathMaskParams>): Partial<FreeformPathMaskParams> { + if (handleId.kind === "position") { return { centerX: startParams.centerX + deltaX / bounds.width, centerY: startParams.centerY + deltaY / bounds.height, @@ -211,7 +210,7 @@ function computeCustomMaskParamUpdate({ const pivotX = bounds.cx + startParams.centerX * bounds.width; const pivotY = bounds.cy + startParams.centerY * bounds.height; - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const startAngle = (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / Math.PI; @@ -232,7 +231,7 @@ function computeCustomMaskParamUpdate({ }; } - if (handleId === "feather") { + if (handleId.kind === "feather") { const angleRad = (startParams.rotation * Math.PI) / 180; return computeFeatherUpdate({ startFeather: startParams.feather, @@ -243,7 +242,7 @@ function computeCustomMaskParamUpdate({ }); } - if (handleId === "scale") { + if (handleId.kind === "scale") { const startDistance = Math.hypot( startCanvasX - pivotX, startCanvasY - pivotY, @@ -258,8 +257,7 @@ function computeCustomMaskParamUpdate({ }; } - const parsedHandle = parseCustomMaskHandleId({ handleId }); - if (!parsedHandle) { + if (handleId.kind !== "anchor") { return {}; } @@ -268,7 +266,7 @@ function computeCustomMaskParamUpdate({ x: startCanvasX + deltaX, y: startCanvasY + deltaY, }; - const localPoint = customMaskCanvasPointToLocal({ + const localPoint = freeformCanvasPointToLocal({ point: currentPoint, centerX: startParams.centerX, centerY: startParams.centerY, @@ -278,45 +276,27 @@ function computeCustomMaskParamUpdate({ }); return { - path: updateCustomMaskPoint({ + path: updateFreeformPathMaskPoint({ points, - pointId: parsedHandle.pointId, - updater: (point) => { - if (parsedHandle.part === "anchor") { - return { - ...point, - x: localPoint.x, - y: localPoint.y, - }; - } - - if (parsedHandle.part === "in") { - return { - ...point, - inX: localPoint.x - point.x, - inY: localPoint.y - point.y, - }; - } - - return { - ...point, - outX: localPoint.x - point.x, - outY: localPoint.y - point.y, - }; - }, + pointId: handleId.pointId, + updater: (point) => ({ + ...point, + x: localPoint.x, + y: localPoint.y, + }), }), }; } -export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { - type: "custom", - name: "Custom", +export const freeformMaskDefinition: MaskDefinition<"freeform"> = { + type: "freeform", + name: "Pen tool", features: { hasPosition: true, hasRotation: true, sizeMode: "uniform", }, - params: CUSTOM_MASK_PARAMS, + params: FREEFORM_PATH_MASK_PARAMS, interaction: { getInteraction({ params, @@ -325,7 +305,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { scaleX: _scaleX, scaleY: _scaleY, }) { - return getCustomMaskDisplayHandles({ params, bounds, displayScale }); + return getFreeformDisplayHandles({ params, bounds, displayScale }); }, snap({ handleId, @@ -336,7 +316,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { snapThreshold, }) { const points = startParams.path; - const localBounds = getCustomMaskLocalBounds({ points, bounds }); + const localBounds = getFreeformLocalBounds({ points, bounds }); if (!startParams.closed || !localBounds) { return { params: proposedParams, @@ -349,7 +329,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { y: proposedParams.centerY * bounds.height, }; - if (handleId === "position") { + if (handleId.kind === "position") { const { snappedPosition, activeLines } = snapPosition({ proposedPosition: position, canvasSize: bounds, @@ -377,7 +357,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { }; } - if (handleId === "rotation") { + if (handleId.kind === "rotation") { const { snappedRotation } = snapRotation({ proposedRotation: proposedParams.rotation, }); @@ -390,7 +370,7 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { }; } - if (handleId === "scale") { + if (handleId.kind === "scale") { const { snappedScale, activeLines } = snapScale({ proposedScale: proposedParams.scale, position, @@ -424,9 +404,9 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { }; }, }, - buildDefault(): Omit<CustomMask, "id"> { + buildDefault(): Omit<FreeformPathMask, "id"> { return { - type: "custom", + type: "freeform", params: { feather: 0, inverted: false, @@ -442,89 +422,95 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { }, }; }, - computeParamUpdate: computeCustomMaskParamUpdate, + computeParamUpdate: computeFreeformParamUpdate, isActive(params) { return params.closed; }, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as CustomMaskParams; - const points = params.path; - if (!params.closed) { - return new Path2D(); - } + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const points = params.path; + if (!params.closed) { + return new Path2D(); + } - return buildCustomMaskPath2D({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds: { - cx: width / 2, - cy: height / 2, - width, - height, - rotation: 0, - }, - closed: true, - }); + return buildFreeformPath2D({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds: { + cx: width / 2, + cy: height / 2, + width, + height, + rotation: 0, + }, + closed: true, + }); + }, }, - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as CustomMaskParams; - if (!params.closed) { - return; - } + stroke: { + kind: "renderStroke", + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + if (!params.closed) { + return; + } - const points = params.path; - const path = buildCustomMaskPath2D({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds: { - cx: width / 2, - cy: height / 2, - width, - height, - rotation: 0, - }, - closed: true, - }); + const points = params.path; + const path = buildFreeformPath2D({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds: { + cx: width / 2, + cy: height / 2, + width, + height, + rotation: 0, + }, + closed: true, + }); - ctx.save(); - ctx.strokeStyle = params.strokeColor; - ctx.lineWidth = params.strokeWidth; - ctx.lineJoin = "round"; - ctx.lineCap = "round"; - ctx.stroke(path); + ctx.save(); + ctx.strokeStyle = params.strokeColor; + ctx.lineWidth = params.strokeWidth; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.stroke(path); - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; - ctx.fillStyle = "#ffffff"; - ctx.fill(path); - } + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - ctx.fillStyle = "#ffffff"; - ctx.fill(path); - } - ctx.restore(); + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } + ctx.restore(); + }, }, }, }; -export function appendPointToCustomMask({ +export function appendPointToFreeformPathMask({ params, canvasPoint, bounds, }: { - params: CustomMaskParams; + params: FreeformPathMaskParams; canvasPoint: { x: number; y: number }; bounds: ElementBounds; -}): CustomMaskParams { +}): FreeformPathMaskParams { const points = params.path; if (points.length === 0) { @@ -550,7 +536,7 @@ export function appendPointToCustomMask({ }; } - const localPoint = customMaskCanvasPointToLocal({ + const localPoint = freeformCanvasPointToLocal({ point: canvasPoint, centerX: params.centerX, centerY: params.centerY, @@ -570,7 +556,7 @@ export function appendPointToCustomMask({ outY: 0, }, ]; - const recentered = recenterCustomMaskPath({ + const recentered = recenterFreeformPath({ points: nextPoints, centerX: params.centerX, centerY: params.centerY, @@ -587,25 +573,25 @@ export function appendPointToCustomMask({ }; } -export function insertPointOnCustomMaskSegment({ +export function insertPointOnFreeformSegment({ params, segmentIndex, canvasPoint, bounds, pointId = generateUUID(), }: { - params: CustomMaskParams; + params: FreeformPathMaskParams; segmentIndex: number; canvasPoint: { x: number; y: number }; bounds: ElementBounds; pointId?: string; -}): { params: CustomMaskParams; pointId: string } | null { +}): { params: FreeformPathMaskParams; pointId: string } | null { const points = params.path; - if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) { + if (getFreeformSegmentCount({ points, closed: params.closed }) === 0) { return null; } - const closestPoint = findClosestPointOnCustomMaskSegment({ + const closestPoint = findClosestPointOnFreeformSegment({ points, segmentIndex, canvasPoint, @@ -620,7 +606,7 @@ export function insertPointOnCustomMaskSegment({ return null; } - const nextPoints = insertPointIntoCustomMaskSegment({ + const nextPoints = insertPointIntoFreeformSegment({ points, segmentIndex, pointId, @@ -631,7 +617,7 @@ export function insertPointOnCustomMaskSegment({ return null; } - const recentered = recenterCustomMaskPath({ + const recentered = recenterFreeformPath({ points: nextPoints, centerX: params.centerX, centerY: params.centerY, diff --git a/apps/web/src/masks/custom-path.ts b/apps/web/src/masks/freeform/path.ts index 0349df63..fa6217c2 100644 --- a/apps/web/src/masks/custom-path.ts +++ b/apps/web/src/masks/freeform/path.ts @@ -1,6 +1,6 @@ import type { ElementBounds } from "@/preview/element-bounds"; -export interface CustomMaskPathPoint { +export interface FreeformPathPoint { id: string; x: number; y: number; @@ -10,88 +10,61 @@ export interface CustomMaskPathPoint { outY: number; } -export type CustomMaskHandlePart = "anchor" | "in" | "out"; - -function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { +function isFreeformPathPoint(value: unknown): value is FreeformPathPoint { if (!value || typeof value !== "object") { return false; } - const candidate = value as Record<string, unknown>; return ( - typeof candidate.id === "string" && - typeof candidate.x === "number" && - typeof candidate.y === "number" && - typeof candidate.inX === "number" && - typeof candidate.inY === "number" && - typeof candidate.outX === "number" && - typeof candidate.outY === "number" + "id" in value && + typeof value.id === "string" && + "x" in value && + typeof value.x === "number" && + "y" in value && + typeof value.y === "number" && + "inX" in value && + typeof value.inX === "number" && + "inY" in value && + typeof value.inY === "number" && + "outX" in value && + typeof value.outX === "number" && + "outY" in value && + typeof value.outY === "number" ); } -export function parseCustomMaskHandleId({ - handleId, -}: { - handleId: string; -}): { pointId: string; part: CustomMaskHandlePart } | null { - const match = /^point:(.+):(anchor|in|out)$/.exec(handleId); - if (!match) { - return null; - } - - return { - pointId: match[1], - part: match[2] as CustomMaskHandlePart, - }; -} - -export function parseCustomMaskSegmentHandleId({ - handleId, -}: { - handleId: string; -}): { segmentIndex: number } | null { - const match = /^segment:(\d+)$/.exec(handleId); - if (!match) { - return null; - } - - return { - segmentIndex: Number.parseInt(match[1], 10), - }; -} - -export function parseCustomMaskPath({ +export function parseFreeformPath({ path, }: { path: string; -}): CustomMaskPathPoint[] { +}): FreeformPathPoint[] { if (!path) { return []; } try { const parsed = JSON.parse(path); - return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; + return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : []; } catch { return []; } } -export function serializeCustomMaskPath({ +export function serializeFreeformPath({ points, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; }): string { return JSON.stringify(points); } -export function removeCustomMaskPoints({ +export function removeFreeformPathPoints({ points, pointIds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; pointIds: string[]; -}): CustomMaskPathPoint[] { +}): FreeformPathPoint[] { if (pointIds.length === 0) { return points; } @@ -99,7 +72,7 @@ export function removeCustomMaskPoints({ return points.filter((point) => !pointIdsToRemove.has(point.id)); } -export function getCustomMaskClosedStateAfterPointRemoval({ +export function getFreeformPathClosedStateAfterPointRemoval({ wasClosed, remainingPointCount, }: { @@ -127,7 +100,7 @@ function rotatePoint({ }; } -export function getCustomMaskCenterCanvasPoint({ +export function getFreeformCenterCanvasPoint({ centerX, centerY, bounds, @@ -142,7 +115,7 @@ export function getCustomMaskCenterCanvasPoint({ }; } -export function customMaskLocalPointToCanvas({ +export function freeformLocalPointToCanvas({ point, centerX, centerY, @@ -157,7 +130,7 @@ export function customMaskLocalPointToCanvas({ scale: number; bounds: ElementBounds; }): { x: number; y: number } { - const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); + const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds }); const scaledLocal = { x: point.x * bounds.width * scale, y: point.y * bounds.height * scale, @@ -174,7 +147,7 @@ export function customMaskLocalPointToCanvas({ }; } -export function customMaskCanvasPointToLocal({ +export function freeformCanvasPointToLocal({ point, centerX, centerY, @@ -189,7 +162,7 @@ export function customMaskCanvasPointToLocal({ scale: number; bounds: ElementBounds; }): { x: number; y: number } { - const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); + const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds }); const translated = { x: point.x - center.x, y: point.y - center.y, @@ -206,7 +179,7 @@ export function customMaskCanvasPointToLocal({ }; } -export function getCustomMaskCanvasGeometry({ +export function getFreeformCanvasGeometry({ points, centerX, centerY, @@ -214,7 +187,7 @@ export function getCustomMaskCanvasGeometry({ scale, bounds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -223,7 +196,7 @@ export function getCustomMaskCanvasGeometry({ }) { const anchors = points.map((point) => ({ id: point.id, - anchor: customMaskLocalPointToCanvas({ + anchor: freeformLocalPointToCanvas({ point: { x: point.x, y: point.y }, centerX, centerY, @@ -231,7 +204,7 @@ export function getCustomMaskCanvasGeometry({ scale, bounds, }), - inHandle: customMaskLocalPointToCanvas({ + inHandle: freeformLocalPointToCanvas({ point: { x: point.x + point.inX, y: point.y + point.inY }, centerX, centerY, @@ -239,7 +212,7 @@ export function getCustomMaskCanvasGeometry({ scale, bounds, }), - outHandle: customMaskLocalPointToCanvas({ + outHandle: freeformLocalPointToCanvas({ point: { x: point.x + point.outX, y: point.y + point.outY }, centerX, centerY, @@ -315,7 +288,7 @@ function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): { }; } -export interface CustomMaskCanvasSegment { +export interface FreeformCanvasSegment { index: number; startPointId: string; endPointId: string; @@ -330,13 +303,27 @@ function clampUnit(value: number): number { return Math.min(1, Math.max(0, value)); } -function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number { +function getDistanceSquared({ + a, + b, +}: { + a: CanvasPoint; + b: CanvasPoint; +}): number { const dx = a.x - b.x; const dy = a.y - b.y; return dx * dx + dy * dy; } -function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint { +function lerpPoint({ + a, + b, + t, +}: { + a: CanvasPoint; + b: CanvasPoint; + t: number; +}): CanvasPoint { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, @@ -371,16 +358,16 @@ function evaluateCubicBezier({ }; } -function getCustomMaskSegmentIndices({ +function getFreeformSegmentIndices({ points, segmentIndex, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; segmentIndex: number; closed: boolean; }): { startIndex: number; endIndex: number } | null { - const segmentCount = getCustomMaskSegmentCount({ points, closed }); + const segmentCount = getFreeformSegmentCount({ points, closed }); if (segmentIndex < 0 || segmentIndex >= segmentCount) { return null; } @@ -391,11 +378,11 @@ function getCustomMaskSegmentIndices({ }; } -export function getCustomMaskSegmentCount({ +export function getFreeformSegmentCount({ points, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; closed: boolean; }): number { if (points.length < 2) { @@ -404,7 +391,7 @@ export function getCustomMaskSegmentCount({ return closed ? points.length : points.length - 1; } -export function getCustomMaskCanvasSegments({ +export function getFreeformCanvasSegments({ points, centerX, centerY, @@ -413,15 +400,15 @@ export function getCustomMaskCanvasSegments({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; closed: boolean; -}): CustomMaskCanvasSegment[] { - const geometry = getCustomMaskCanvasGeometry({ +}): FreeformCanvasSegment[] { + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, @@ -429,7 +416,7 @@ export function getCustomMaskCanvasSegments({ scale, bounds, }); - const segmentCount = getCustomMaskSegmentCount({ points, closed }); + const segmentCount = getFreeformSegmentCount({ points, closed }); return Array.from({ length: segmentCount }, (_, segmentIndex) => { const start = geometry.anchors[segmentIndex]; @@ -447,7 +434,7 @@ export function getCustomMaskCanvasSegments({ }); } -export function findClosestPointOnCustomMaskSegment({ +export function findClosestPointOnFreeformSegment({ points, segmentIndex, canvasPoint, @@ -458,7 +445,7 @@ export function findClosestPointOnCustomMaskSegment({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; segmentIndex: number; canvasPoint: CanvasPoint; centerX: number; @@ -468,7 +455,7 @@ export function findClosestPointOnCustomMaskSegment({ bounds: ElementBounds; closed: boolean; }): { t: number; point: CanvasPoint } | null { - const segment = getCustomMaskCanvasSegments({ + const segment = getFreeformCanvasSegments({ points, centerX, centerY, @@ -483,7 +470,10 @@ export function findClosestPointOnCustomMaskSegment({ const sampleCount = 24; let bestT = 0; - let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start); + let bestDistanceSquared = getDistanceSquared({ + a: canvasPoint, + b: segment.start, + }); for (let step = 0; step <= sampleCount; step++) { const t = step / sampleCount; @@ -494,7 +484,7 @@ export function findClosestPointOnCustomMaskSegment({ p3: segment.end, t, }); - const distanceSquared = getDistanceSquared(canvasPoint, point); + const distanceSquared = getDistanceSquared({ a: canvasPoint, b: point }); if (distanceSquared < bestDistanceSquared) { bestDistanceSquared = distanceSquared; bestT = t; @@ -516,7 +506,10 @@ export function findClosestPointOnCustomMaskSegment({ }), })); for (const candidate of candidates) { - const distanceSquared = getDistanceSquared(canvasPoint, candidate.point); + const distanceSquared = getDistanceSquared({ + a: canvasPoint, + b: candidate.point, + }); if (distanceSquared < bestDistanceSquared) { bestDistanceSquared = distanceSquared; bestT = candidate.t; @@ -538,20 +531,20 @@ export function findClosestPointOnCustomMaskSegment({ }; } -export function insertPointIntoCustomMaskSegment({ +export function insertPointIntoFreeformSegment({ points, segmentIndex, pointId, t, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; segmentIndex: number; pointId: string; t: number; closed: boolean; -}): CustomMaskPathPoint[] { - const indices = getCustomMaskSegmentIndices({ +}): FreeformPathPoint[] { + const indices = getFreeformSegmentIndices({ points, segmentIndex, closed, @@ -573,12 +566,12 @@ export function insertPointIntoCustomMaskSegment({ y: endPoint.y + endPoint.inY, }; const p3 = { x: endPoint.x, y: endPoint.y }; - const p01 = lerpPoint(p0, p1, clampedT); - const p12 = lerpPoint(p1, p2, clampedT); - const p23 = lerpPoint(p2, p3, clampedT); - const p012 = lerpPoint(p01, p12, clampedT); - const p123 = lerpPoint(p12, p23, clampedT); - const splitPoint = lerpPoint(p012, p123, clampedT); + const p01 = lerpPoint({ a: p0, b: p1, t: clampedT }); + const p12 = lerpPoint({ a: p1, b: p2, t: clampedT }); + const p23 = lerpPoint({ a: p2, b: p3, t: clampedT }); + const p012 = lerpPoint({ a: p01, b: p12, t: clampedT }); + const p123 = lerpPoint({ a: p12, b: p23, t: clampedT }); + const splitPoint = lerpPoint({ a: p012, b: p123, t: clampedT }); const nextPoints = [...points]; nextPoints[indices.startIndex] = { @@ -603,11 +596,11 @@ export function insertPointIntoCustomMaskSegment({ return nextPoints; } -export function getCustomMaskLocalBounds({ +export function getFreeformLocalBounds({ points, bounds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; bounds: ElementBounds; }) { if (points.length === 0) { @@ -639,7 +632,7 @@ export function getCustomMaskLocalBounds({ }; } -export function recenterCustomMaskPath({ +export function recenterFreeformPath({ points, centerX, centerY, @@ -647,7 +640,7 @@ export function recenterCustomMaskPath({ scale, bounds, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -658,7 +651,7 @@ export function recenterCustomMaskPath({ return { centerX, centerY, points }; } - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, @@ -683,7 +676,7 @@ export function recenterCustomMaskPath({ }; const nextPoints = geometry.anchors.map((point) => { - const anchor = customMaskCanvasPointToLocal({ + const anchor = freeformCanvasPointToLocal({ point: point.anchor, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, @@ -691,7 +684,7 @@ export function recenterCustomMaskPath({ scale, bounds, }); - const inHandle = customMaskCanvasPointToLocal({ + const inHandle = freeformCanvasPointToLocal({ point: point.inHandle, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, @@ -699,7 +692,7 @@ export function recenterCustomMaskPath({ scale, bounds, }); - const outHandle = customMaskCanvasPointToLocal({ + const outHandle = freeformCanvasPointToLocal({ point: point.outHandle, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, @@ -726,7 +719,7 @@ export function recenterCustomMaskPath({ }; } -export function buildCustomMaskPath2D({ +export function buildFreeformPath2D({ points, centerX, centerY, @@ -735,7 +728,7 @@ export function buildCustomMaskPath2D({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -748,7 +741,7 @@ export function buildCustomMaskPath2D({ return path; } - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, @@ -789,7 +782,7 @@ export function buildCustomMaskPath2D({ return path; } -export function buildCustomMaskSvgPath({ +export function buildFreeformSvgPath({ points, centerX, centerY, @@ -798,7 +791,7 @@ export function buildCustomMaskSvgPath({ bounds, closed, }: { - points: CustomMaskPathPoint[]; + points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; @@ -810,7 +803,7 @@ export function buildCustomMaskSvgPath({ return ""; } - const geometry = getCustomMaskCanvasGeometry({ + const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, diff --git a/apps/web/src/masks/handle-positions.ts b/apps/web/src/masks/handle-positions.ts index 75668959..70039d9b 100644 --- a/apps/web/src/masks/handle-positions.ts +++ b/apps/web/src/masks/handle-positions.ts @@ -2,6 +2,7 @@ import { FEATHER_HANDLE_SCALE } from "@/masks/feather"; import type { ElementBounds } from "@/preview/element-bounds"; import type { MaskFeatures, + MaskHandleId, MaskHandlePosition, MaskLineOverlay, MaskOverlay, @@ -68,14 +69,14 @@ export function getLineMaskOverlay({ centerY, rotation, bounds, - handleId = "position", + handleId = { kind: "position" }, cursor = "move", }: { centerX: number; centerY: number; rotation: number; bounds: ElementBounds; - handleId?: string; + handleId?: MaskHandleId; cursor?: string; }): MaskLineOverlay { const { start, end } = getLineMaskLinePoints({ @@ -122,7 +123,7 @@ export function getLineMaskHandlePositions({ return [ { - id: "rotation", + id: { kind: "rotation" }, x: cx + normalX * iconOffsetCanvas, y: cy + normalY * iconOffsetCanvas, cursor: CURSOR.rotate, @@ -130,7 +131,7 @@ export function getLineMaskHandlePositions({ icon: "rotate", }, { - id: "feather", + id: { kind: "feather" }, x: cx - normalX * featherOffset, y: cy - normalY * featherOffset, cursor: CURSOR.resizeHorizontal, @@ -201,7 +202,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "rotation", + id: { kind: "rotation" }, x: rotHandle.x, y: rotHandle.y, cursor: CURSOR.rotate, @@ -217,7 +218,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "feather", + id: { kind: "feather" }, x: featherHandle.x, y: featherHandle.y, cursor: CURSOR.resizeVertical, @@ -226,16 +227,36 @@ export function getBoxMaskHandlePositions({ }); if (sizeMode === "width-height") { - const corners = [ - { localX: -halfWidth, localY: -halfHeight, id: "top-left" }, - { localX: halfWidth, localY: -halfHeight, id: "top-right" }, - { localX: halfWidth, localY: halfHeight, id: "bottom-right" }, - { localX: -halfWidth, localY: halfHeight, id: "bottom-left" }, + const corners: { + localX: number; + localY: number; + corner: Extract<MaskHandleId, { kind: "corner" }>["corner"]; + }[] = [ + { + localX: -halfWidth, + localY: -halfHeight, + corner: { x: "left", y: "top" }, + }, + { + localX: halfWidth, + localY: -halfHeight, + corner: { x: "right", y: "top" }, + }, + { + localX: halfWidth, + localY: halfHeight, + corner: { x: "right", y: "bottom" }, + }, + { + localX: -halfWidth, + localY: halfHeight, + corner: { x: "left", y: "bottom" }, + }, ]; - for (const { localX, localY, id } of corners) { + for (const { localX, localY, corner } of corners) { const point = rotatePoint({ localX, localY, cx, cy, angleRad }); handles.push({ - id, + id: { kind: "corner", corner }, x: point.x, y: point.y, cursor: CURSOR.resizeDiagonal, @@ -264,7 +285,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "left", + id: { kind: "edge", side: "left" }, x: left.x, y: left.y, cursor: CURSOR.resizeHorizontal, @@ -273,7 +294,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "right", + id: { kind: "edge", side: "right" }, x: right.x, y: right.y, cursor: CURSOR.resizeHorizontal, @@ -282,7 +303,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "bottom", + id: { kind: "edge", side: "bottom" }, x: bottom.x, y: bottom.y, cursor: CURSOR.resizeVertical, @@ -306,7 +327,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "top", + id: { kind: "edge", side: "top" }, x: top.x, y: top.y, cursor: CURSOR.resizeVertical, @@ -315,7 +336,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "bottom", + id: { kind: "edge", side: "bottom" }, x: bottom.x, y: bottom.y, cursor: CURSOR.resizeVertical, @@ -339,7 +360,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "left", + id: { kind: "edge", side: "left" }, x: left.x, y: left.y, cursor: CURSOR.resizeHorizontal, @@ -348,7 +369,7 @@ export function getBoxMaskHandlePositions({ rotation, }); handles.push({ - id: "right", + id: { kind: "edge", side: "right" }, x: right.x, y: right.y, cursor: CURSOR.resizeHorizontal, @@ -365,7 +386,7 @@ export function getBoxMaskHandlePositions({ angleRad, }); handles.push({ - id: "scale", + id: { kind: "scale" }, x: point.x, y: point.y, cursor: CURSOR.resizeDiagonal, @@ -383,7 +404,7 @@ export function getBoxMaskRectOverlay({ height, rotation, bounds, - handleId = "position", + handleId = { kind: "position" }, cursor = "move", dashed = false, }: { @@ -393,7 +414,7 @@ export function getBoxMaskRectOverlay({ height: number; rotation: number; bounds: ElementBounds; - handleId?: string; + handleId?: MaskHandleId; cursor?: string; dashed?: boolean; }): MaskRectOverlay { @@ -421,7 +442,7 @@ export function getBoxMaskShapeOverlay({ rotation, bounds, pathData, - handleId = "position", + handleId = { kind: "position" }, cursor = "move", }: { centerX: number; @@ -431,7 +452,7 @@ export function getBoxMaskShapeOverlay({ rotation: number; bounds: ElementBounds; pathData: string; - handleId?: string; + handleId?: MaskHandleId; cursor?: string; }): MaskShapeOverlay { return { diff --git a/apps/web/src/masks/index.ts b/apps/web/src/masks/index.ts index 3544e239..994f05f2 100644 --- a/apps/web/src/masks/index.ts +++ b/apps/web/src/masks/index.ts @@ -1,18 +1,58 @@ -import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
-import { masksRegistry } from "./registry";
-import { generateUUID } from "@/utils/id";
-
-export { masksRegistry } from "./registry";
-export { registerDefaultMasks } from "./definitions";
-
-export function buildDefaultMaskInstance({
- maskType,
- elementSize,
-}: {
- maskType: MaskType;
- elementSize?: { width: number; height: number };
-}): Mask {
- const definition = masksRegistry.get(maskType);
- const context: MaskDefaultContext = { elementSize };
- return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
-}
+import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types"; +import { masksRegistry } from "./registry"; +import { generateUUID } from "@/utils/id"; + +export { masksRegistry } from "./registry"; +export { registerDefaultMasks } from "./builtin/definitions"; + +type MaskWithoutId = Mask extends infer TMask + ? TMask extends Mask + ? Omit<TMask, "id"> + : never + : never; + +function withMaskId({ mask, id }: { mask: MaskWithoutId; id: string }): Mask { + switch (mask.type) { + case "split": + return { ...mask, id }; + case "cinematic-bars": + return { ...mask, id }; + case "rectangle": + return { ...mask, id }; + case "ellipse": + return { ...mask, id }; + case "heart": + return { ...mask, id }; + case "diamond": + return { ...mask, id }; + case "star": + return { ...mask, id }; + case "text": + return { ...mask, id }; + case "freeform": + return { ...mask, id }; + } +} + +export function getMaskDefinition(maskType: MaskType) { + return masksRegistry.get(maskType); +} + +export function getMaskDefinitionsForMenu() { + return masksRegistry.getAll(); +} + +export function buildDefaultMaskInstance({ + maskType, + elementSize, +}: { + maskType: MaskType; + elementSize?: { width: number; height: number }; +}): Mask { + const definition = masksRegistry.get(maskType); + const context: MaskDefaultContext = { elementSize }; + return withMaskId({ + mask: definition.buildDefault(context), + id: generateUUID(), + }); +} diff --git a/apps/web/src/masks/registry.ts b/apps/web/src/masks/registry.ts index 00b276c8..35d215c6 100644 --- a/apps/web/src/masks/registry.ts +++ b/apps/web/src/masks/registry.ts @@ -2,11 +2,11 @@ import { MAX_FEATHER } from "@/masks/feather"; import type { ParamDefinition } from "@/params"; import type { BaseMaskParams, + Mask, MaskDefaultContext, MaskDefinition, - MaskInteractionResult, - MaskSnapArgs, - MaskSnapResult, + MaskParamUpdateArgs, + MaskRenderer, MaskType, } from "@/masks/types"; import type { HugeiconsIconProps } from "@hugeicons/react"; @@ -17,7 +17,17 @@ export type MaskIconProps = { strokeWidth?: number; }; -const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< +type RegisteredMaskWithoutId = Mask extends infer TMask + ? TMask extends Mask + ? Omit<TMask, "id"> + : never + : never; + +export type MaskDefinitionForRegistration = { + [TType in MaskType]: MaskDefinition<TType>; +}[MaskType]; + +export const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< keyof BaseMaskParams & string >[] = [ { @@ -50,28 +60,15 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< export interface RegisteredMaskDefinition { type: MaskType; name: string; - features: MaskDefinition<BaseMaskParams>["features"]; + features: MaskDefinition["features"]; params: ParamDefinition<string>[]; - renderer: MaskDefinition<BaseMaskParams>["renderer"]; - interaction: { - getInteraction(args: { - params: BaseMaskParams; - bounds: Parameters< - MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"] - >[0]["bounds"]; - displayScale: number; - scaleX: number; - scaleY: number; - }): MaskInteractionResult; - snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>; - }; - isActive?: (params: BaseMaskParams) => boolean; - buildDefault( - context: MaskDefaultContext, - ): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>; + renderer: MaskRenderer<BaseMaskParams>; + interaction: MaskDefinition["interaction"]; + isActive?(params: BaseMaskParams): boolean; + buildDefault(context: MaskDefaultContext): RegisteredMaskWithoutId; computeParamUpdate( - args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0], - ): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>; + args: MaskParamUpdateArgs<BaseMaskParams>, + ): Partial<BaseMaskParams>; icon: MaskIconProps; } @@ -83,11 +80,11 @@ export class MasksRegistry extends DefinitionRegistry< super("mask"); } - registerMask<TParams extends BaseMaskParams>({ + registerMask({ definition, icon, }: { - definition: MaskDefinition<TParams>; + definition: MaskDefinitionForRegistration; icon: MaskIconProps; }): void { const withBaseParams: RegisteredMaskDefinition = { @@ -96,26 +93,16 @@ export class MasksRegistry extends DefinitionRegistry< features: definition.features, params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS], renderer: definition.renderer, - interaction: { - getInteraction(args) { - return definition.interaction.getInteraction(args as never); - }, - snap: definition.interaction.snap - ? (args) => definition.interaction.snap?.(args as never) as never - : undefined, - }, - isActive: definition.isActive - ? (params) => definition.isActive?.(params as TParams) ?? true - : undefined, - buildDefault(context) { - return definition.buildDefault(context); - }, - computeParamUpdate(args) { - return definition.computeParamUpdate(args as never); - }, + interaction: definition.interaction, + isActive: definition.isActive, + buildDefault: definition.buildDefault, + computeParamUpdate: definition.computeParamUpdate, icon, }; - this.register(definition.type, withBaseParams); + this.register({ + key: definition.type, + definition: withBaseParams, + }); } } diff --git a/apps/web/src/masks/snap.ts b/apps/web/src/masks/snap.ts index cff62ebb..519783a9 100644 --- a/apps/web/src/masks/snap.ts +++ b/apps/web/src/masks/snap.ts @@ -6,30 +6,20 @@ import { snapScale, snapScaleAxes, type ScaleEdgePreference, - type SnapLine, } from "@/preview/preview-snap"; -import type { RectangleMaskParams, SplitMaskParams } from "@/masks/types"; +import type { + MaskHandleId, + MaskSnapArgs, + MaskSnapResult, + RectangleMaskParams, + SplitMaskParams, +} from "@/masks/types"; import { - isRectangleMaskParams, getMaskSnapGeometry, setMaskLocalCenter, toGlobalMaskSnapLines, } from "./geometry"; -type SharedMaskParams = SplitMaskParams | RectangleMaskParams; - -type MaskSnapResult<TParams extends SharedMaskParams> = { - params: TParams; - activeLines: SnapLine[]; -}; - -const CORNER_SIZE_HANDLES = new Set([ - "top-left", - "top-right", - "bottom-left", - "bottom-right", -]); - function getClampedRatio({ next, base, @@ -45,28 +35,27 @@ function getClampedRatio({ function getPreferredEdges({ handleId, }: { - handleId: string; + handleId: MaskHandleId; }): ScaleEdgePreference | undefined { - const preferredEdges = { - left: - handleId === "left" || - handleId === "top-left" || - handleId === "bottom-left", - right: - handleId === "right" || - handleId === "top-right" || - handleId === "bottom-right", - top: - handleId === "top" || handleId === "top-left" || handleId === "top-right", - bottom: - handleId === "bottom" || - handleId === "bottom-left" || - handleId === "bottom-right", - } satisfies ScaleEdgePreference; + if (handleId.kind === "edge") { + return { + left: handleId.side === "left", + right: handleId.side === "right", + top: handleId.side === "top", + bottom: handleId.side === "bottom", + }; + } - return Object.values(preferredEdges).some(Boolean) - ? preferredEdges - : undefined; + if (handleId.kind === "corner") { + return { + left: handleId.corner.x === "left", + right: handleId.corner.x === "right", + top: handleId.corner.y === "top", + bottom: handleId.corner.y === "bottom", + }; + } + + return undefined; } function snapMaskPosition({ @@ -75,11 +64,33 @@ function snapMaskPosition({ canvasSize, snapThreshold, }: { - proposedParams: SharedMaskParams; + proposedParams: RectangleMaskParams; bounds: ElementBounds; canvasSize: { width: number; height: number }; snapThreshold: { x: number; y: number }; -}): MaskSnapResult<SharedMaskParams> { +}): MaskSnapResult<RectangleMaskParams>; +function snapMaskPosition({ + proposedParams, + bounds, + canvasSize, + snapThreshold, +}: { + proposedParams: SplitMaskParams; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +}): MaskSnapResult<SplitMaskParams>; +function snapMaskPosition({ + proposedParams, + bounds, + canvasSize, + snapThreshold, +}: { + proposedParams: RectangleMaskParams | SplitMaskParams; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +}): MaskSnapResult<RectangleMaskParams | SplitMaskParams> { const geometry = getMaskSnapGeometry({ params: proposedParams, bounds, @@ -115,12 +126,18 @@ function snapMaskPosition({ function snapMaskRotation({ proposedParams, }: { - proposedParams: SharedMaskParams; -}): MaskSnapResult<SharedMaskParams> { - if (typeof proposedParams.rotation !== "number") { - return { params: proposedParams, activeLines: [] }; - } - + proposedParams: RectangleMaskParams; +}): MaskSnapResult<RectangleMaskParams>; +function snapMaskRotation({ + proposedParams, +}: { + proposedParams: SplitMaskParams; +}): MaskSnapResult<SplitMaskParams>; +function snapMaskRotation({ + proposedParams, +}: { + proposedParams: RectangleMaskParams | SplitMaskParams; +}): MaskSnapResult<RectangleMaskParams | SplitMaskParams> { const { snappedRotation } = snapRotation({ proposedRotation: proposedParams.rotation, }); @@ -142,20 +159,13 @@ function snapBoxMaskSize({ canvasSize, snapThreshold, }: { - handleId: string; - startParams: SharedMaskParams; - proposedParams: SharedMaskParams; + handleId: MaskHandleId; + startParams: RectangleMaskParams; + proposedParams: RectangleMaskParams; bounds: ElementBounds; canvasSize: { width: number; height: number }; snapThreshold: { x: number; y: number }; -}): MaskSnapResult<SharedMaskParams> { - if ( - !isRectangleMaskParams(startParams) || - !isRectangleMaskParams(proposedParams) - ) { - return { params: proposedParams, activeLines: [] }; - } - +}): MaskSnapResult<RectangleMaskParams> { const geometry = getMaskSnapGeometry({ params: proposedParams, bounds, @@ -171,7 +181,10 @@ function snapBoxMaskSize({ Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height; const preferredEdges = getPreferredEdges({ handleId }); - if (handleId === "right" || handleId === "left") { + if ( + handleId.kind === "edge" && + (handleId.side === "right" || handleId.side === "left") + ) { const proposedScaleX = getClampedRatio({ next: proposedParams.width, base: startParams.width, @@ -201,7 +214,10 @@ function snapBoxMaskSize({ }; } - if (handleId === "top" || handleId === "bottom") { + if ( + handleId.kind === "edge" && + (handleId.side === "top" || handleId.side === "bottom") + ) { const proposedScaleY = getClampedRatio({ next: proposedParams.height, base: startParams.height, @@ -234,7 +250,7 @@ function snapBoxMaskSize({ }; } - if (handleId === "scale") { + if (handleId.kind === "scale") { const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION); const proposedScale = getClampedRatio({ next: proposedParams.scale, @@ -264,7 +280,7 @@ function snapBoxMaskSize({ }; } - if (CORNER_SIZE_HANDLES.has(handleId)) { + if (handleId.kind === "corner") { const proposedScale = getClampedRatio({ next: proposedParams.width, base: startParams.width, @@ -297,7 +313,7 @@ function snapBoxMaskSize({ return { params: proposedParams, activeLines: [] }; } -export function snapMaskInteraction<TParams extends SharedMaskParams>({ +export function snapBoxMaskInteraction({ handleId, startParams, proposedParams, @@ -305,24 +321,24 @@ export function snapMaskInteraction<TParams extends SharedMaskParams>({ canvasSize, snapThreshold, }: { - handleId: string; - startParams: TParams; - proposedParams: TParams; + handleId: MaskHandleId; + startParams: RectangleMaskParams; + proposedParams: RectangleMaskParams; bounds: ElementBounds; canvasSize: { width: number; height: number }; snapThreshold: { x: number; y: number }; -}): MaskSnapResult<TParams> { - if (handleId === "position") { +}): MaskSnapResult<RectangleMaskParams> { + if (handleId.kind === "position") { return snapMaskPosition({ proposedParams, bounds, canvasSize, snapThreshold, - }) as MaskSnapResult<TParams>; + }); } - if (handleId === "rotation") { - return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>; + if (handleId.kind === "rotation") { + return snapMaskRotation({ proposedParams }); } return snapBoxMaskSize({ @@ -332,5 +348,28 @@ export function snapMaskInteraction<TParams extends SharedMaskParams>({ bounds, canvasSize, snapThreshold, - }) as MaskSnapResult<TParams>; + }); +} + +export function snapSplitMaskInteraction({ + handleId, + proposedParams, + bounds, + canvasSize, + snapThreshold, +}: MaskSnapArgs<SplitMaskParams>): MaskSnapResult<SplitMaskParams> { + if (handleId.kind === "position") { + return snapMaskPosition({ + proposedParams, + bounds, + canvasSize, + snapThreshold, + }); + } + + if (handleId.kind === "rotation") { + return snapMaskRotation({ proposedParams }); + } + + return { params: proposedParams, activeLines: [] }; } diff --git a/apps/web/src/masks/types.ts b/apps/web/src/masks/types.ts index 4f6c90aa..afc79a8c 100644 --- a/apps/web/src/masks/types.ts +++ b/apps/web/src/masks/types.ts @@ -1,14 +1,14 @@ import type { ElementBounds } from "@/preview/element-bounds"; import type { SnapLine } from "@/preview/preview-snap"; import type { ParamDefinition } from "@/params"; -import type { CustomMaskPathPoint } from "@/masks/custom-path"; +import type { FreeformPathPoint } from "@/masks/freeform/path"; import type { TextDecoration, TextFontStyle, TextFontWeight, } from "@/text/primitives"; -export type MaskType = +export type BuiltinMaskType = | "split" | "cinematic-bars" | "rectangle" @@ -16,8 +16,9 @@ export type MaskType = | "heart" | "diamond" | "star" - | "text" - | "custom"; + | "text"; + +export type MaskType = BuiltinMaskType | "freeform"; export interface BaseMaskParams { feather: number; @@ -57,8 +58,8 @@ export interface TextMaskParams extends BaseMaskParams { scale: number; } -export interface CustomMaskParams extends BaseMaskParams { - path: CustomMaskPathPoint[]; +export interface FreeformPathMaskParams extends BaseMaskParams { + path: FreeformPathPoint[]; closed: boolean; centerX: number; centerY: number; @@ -114,13 +115,7 @@ export interface TextMask { params: TextMaskParams; } -export interface CustomMask { - id: string; - type: "custom"; - params: CustomMaskParams; -} - -export type Mask = +export type BuiltinShapeMask = | SplitMask | CinematicBarsMask | RectangleMask @@ -128,35 +123,60 @@ export type Mask = | HeartMask | DiamondMask | StarMask - | TextMask - | CustomMask; + | TextMask; + +export interface FreeformPathMask { + id: string; + type: "freeform"; + params: FreeformPathMaskParams; +} + +export type Mask = BuiltinShapeMask | FreeformPathMask; + +export type MaskByType<TType extends MaskType> = Extract<Mask, { type: TType }>; +export type MaskParamsByType<TType extends MaskType> = + MaskByType<TType>["params"]; + +type MaskPathArgs<TParams extends BaseMaskParams> = { + resolvedParams: TParams; + width: number; + height: number; +}; + +type MaskDrawArgs<TParams extends BaseMaskParams> = MaskPathArgs<TParams> & { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +}; + +export type MaskBody<TParams extends BaseMaskParams = BaseMaskParams> = + | { + kind: "fillPath"; + buildPath(args: MaskPathArgs<TParams>): Path2D; + } + | { + kind: "drawOpaque"; + drawOpaque(args: MaskDrawArgs<TParams>): void; + } + | { + kind: "drawWithFeather"; + drawWithFeather(args: MaskDrawArgs<TParams> & { feather: number }): void; + opaqueFastPath?: { + buildPath(args: MaskPathArgs<TParams>): Path2D; + }; + }; -export interface MaskRenderer { - buildPath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - buildStrokePath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - /** Renders the feathered mask directly onto ctx, bypassing JFA. */ - renderMask?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - feather: number; - }) => void; - renderMaskHandlesFeather?: boolean; - renderStroke?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - }) => void; +export type MaskStroke<TParams extends BaseMaskParams = BaseMaskParams> = + | { + kind: "strokeFromPath"; + buildStrokePath(args: MaskPathArgs<TParams>): Path2D; + } + | { + kind: "renderStroke"; + renderStroke(args: MaskDrawArgs<TParams>): void; + }; + +export interface MaskRenderer<TParams extends BaseMaskParams = BaseMaskParams> { + body: MaskBody<TParams>; + stroke?: MaskStroke<TParams>; } export interface MaskFeatures { @@ -167,10 +187,41 @@ export interface MaskFeatures { export type MaskHandleIcon = "rotate" | "feather"; -export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent"; +export type MaskHandleKind = "corner" | "edge" | "icon" | "point"; + +type Side = "left" | "right" | "top" | "bottom"; +type CornerXY = { x: "left" | "right"; y: "top" | "bottom" }; + +export type MaskHandleId = + | { kind: "position" } + | { kind: "rotation" } + | { kind: "feather" } + | { kind: "scale" } + | { kind: "edge"; side: Side } + | { kind: "corner"; corner: CornerXY } + | { kind: "anchor"; pointId: string } + | { kind: "segment"; index: number }; + +export function maskHandleIdKey({ id }: { id: MaskHandleId }): string { + switch (id.kind) { + case "position": + case "rotation": + case "feather": + case "scale": + return id.kind; + case "edge": + return id.side; + case "corner": + return `${id.corner.y}-${id.corner.x}`; + case "anchor": + return `point:${id.pointId}:anchor`; + case "segment": + return `segment:${id.index}`; + } +} export interface MaskHandlePosition { - id: string; + id: MaskHandleId; x: number; y: number; cursor: string; @@ -187,7 +238,7 @@ export interface MaskLineOverlay { start: { x: number; y: number }; end: { x: number; y: number }; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; } export interface MaskRectOverlay { @@ -199,7 +250,7 @@ export interface MaskRectOverlay { rotation: number; dashed?: boolean; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; } export interface MaskShapeOverlay { @@ -211,7 +262,7 @@ export interface MaskShapeOverlay { rotation: number; pathData: string; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; } export interface MaskCanvasPathOverlay { @@ -220,7 +271,7 @@ export interface MaskCanvasPathOverlay { pathData: string; coordinateSpace?: "canvas" | "overlay"; cursor?: string; - handleId?: string; + handleId?: MaskHandleId; strokeWidth?: number; strokeOpacity?: number; } @@ -238,7 +289,7 @@ export interface MaskDefaultContext { export interface MaskParamUpdateArgs< TParams extends BaseMaskParams = BaseMaskParams, > { - handleId: string; + handleId: MaskHandleId; startParams: TParams; deltaX: number; deltaY: number; @@ -249,7 +300,7 @@ export interface MaskParamUpdateArgs< } export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> { - handleId: string; + handleId: MaskHandleId; startParams: TParams; proposedParams: TParams; bounds: ElementBounds; @@ -282,17 +333,17 @@ export interface MaskInteractionDefinition< snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>; } -export interface MaskDefinition< - TParams extends BaseMaskParams = BaseMaskParams, -> { - type: MaskType; +export interface MaskDefinition<TType extends MaskType = MaskType> { + type: TType; name: string; features: MaskFeatures; - params: ParamDefinition<keyof TParams & string>[]; - renderer: MaskRenderer; - interaction: MaskInteractionDefinition<TParams>; + params: ParamDefinition<keyof MaskParamsByType<TType> & string>[]; + renderer: MaskRenderer<MaskParamsByType<TType>>; + interaction: MaskInteractionDefinition<MaskParamsByType<TType>>; /** When defined and returning false, the mask is not applied and the element renders fully visible. */ - isActive?: (params: TParams) => boolean; - buildDefault(context: MaskDefaultContext): Omit<Mask, "id">; - computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>; + isActive?(params: MaskParamsByType<TType>): boolean; + buildDefault(context: MaskDefaultContext): Omit<MaskByType<TType>, "id">; + computeParamUpdate( + args: MaskParamUpdateArgs<MaskParamsByType<TType>>, + ): Partial<MaskParamsByType<TType>>; } diff --git a/apps/web/src/masks/use-mask-handles.ts b/apps/web/src/masks/use-mask-handles.ts index b81de4f4..f889e029 100644 --- a/apps/web/src/masks/use-mask-handles.ts +++ b/apps/web/src/masks/use-mask-handles.ts @@ -1,13 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { masksRegistry } from "@/masks"; -import { - parseCustomMaskHandleId, - parseCustomMaskSegmentHandleId, -} from "@/masks/custom-path"; -import { appendPointToCustomMask } from "@/masks/definitions/custom"; +import { getMaskDefinition } from "@/masks"; +import { appendPointToFreeformPathMask } from "@/masks/freeform/definition"; import { getVisibleElementsWithBounds, type ElementBounds, @@ -17,14 +13,15 @@ import { type SnapLine, } from "@/preview/preview-snap"; import type { SelectedMaskPointSelection } from "@/selection/editor-selection"; -import type { Mask, MaskInteractionResult } from "@/masks/types"; +import type { Mask, MaskHandleId, MaskInteractionResult } from "@/masks/types"; import type { MaskableElement } from "@/timeline"; +import { isMaskableElement } from "@/timeline/element-utils"; import { registerCanceller } from "@/editor/cancel-interaction"; interface DragState { trackId: string; elementId: string; - handleId: string; + handleId: MaskHandleId; startCanvasX: number; startCanvasY: number; startParams: Mask["params"]; @@ -99,12 +96,12 @@ export function useMaskHandles({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const viewport = usePreviewViewport(); - const [activeHandleId, setActiveHandleId] = useState<string | null>(null); + const [activeHandleId, setActiveHandleId] = useState<MaskHandleId | null>(null); const dragStateRef = useRef<DragState | null>(null); const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>( null, ); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + const captureRef = useRef<{ element: Element; pointerId: number } | null>( null, ); @@ -137,7 +134,8 @@ export function useMaskHandles({ item.trackId === sel.trackId && item.elementId === sel.elementId, ); if (!entry) return null; - const element = entry.element as MaskableElement; + if (!isMaskableElement(entry.element)) return null; + const element = entry.element; const masks = element.masks ?? []; if (masks.length === 0) return null; const activeMaskId = masks.some((mask) => @@ -160,7 +158,7 @@ export function useMaskHandles({ const { handles: baseHandlePositions, overlays }: MaskInteractionResult = selectedWithMask ? (() => { - const def = masksRegistry.get(selectedWithMask.mask.type); + const def = getMaskDefinition(selectedWithMask.mask.type); const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); const displayScale = (scaleX + scaleY) / 2; return def.interaction.getInteraction({ @@ -186,30 +184,29 @@ export function useMaskHandles({ ); const handlePositions = baseHandlePositions.map((handle) => { - const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id }); - if (!parsedHandle || parsedHandle.part !== "anchor") { + if (handle.id.kind !== "anchor") { return handle; } return { ...handle, - isSelected: selectedPointIds.has(parsedHandle.pointId), + isSelected: selectedPointIds.has(handle.id.pointId), }; }); - const customMaskPointIds = - selectedWithMask?.mask.type === "custom" - ? handlePositions - .filter((h) => h.kind === "point") - .map((h) => { - const parsedHandle = parseCustomMaskHandleId({ handleId: h.id }); - return parsedHandle?.part === "anchor" - ? parsedHandle.pointId - : null; - }) - .filter((id): id is string => id !== null) - : []; - const isCreatingCustomMask = - selectedWithMask?.mask.type === "custom" && + const customMaskPointIds = useMemo( + () => + selectedWithMask?.mask.type === "freeform" + ? handlePositions + .filter((h) => h.kind === "point") + .map((h) => { + return h.id.kind === "anchor" ? h.id.pointId : null; + }) + .filter((id): id is string => id !== null) + : [], + [handlePositions, selectedWithMask?.mask.type], + ); + const isCreatingFreeformPathMask = + selectedWithMask?.mask.type === "freeform" && (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0); useEffect(() => { @@ -218,7 +215,7 @@ export function useMaskHandles({ } if ( !selectedWithMask || - selectedWithMask.mask.type !== "custom" || + selectedWithMask.mask.type !== "freeform" || !isMaskSelectionForElement({ trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, @@ -257,7 +254,7 @@ export function useMaskHandles({ selectedWithMask, ]); - const updateCustomMaskPointSelection = useCallback( + const updateFreeformPathMaskPointSelection = useCallback( ({ pointId, toggleSelection, @@ -265,7 +262,7 @@ export function useMaskHandles({ pointId: string; toggleSelection: boolean; }) => { - if (!selectedWithMask || selectedWithMask.mask.type !== "custom") { + if (!selectedWithMask || selectedWithMask.mask.type !== "freeform") { return; } @@ -339,25 +336,32 @@ export function useMaskHandles({ ]); const handlePointerDown = useCallback( - ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => { + ({ + event, + handleId, + }: { + event: React.PointerEvent; + handleId: MaskHandleId; + }) => { if (!selectedWithMask) return; if (event.button !== 0) return; event.stopPropagation(); - const parsedHandle = - selectedWithMask.mask.type === "custom" - ? parseCustomMaskHandleId({ handleId }) + const anchorHandle = + selectedWithMask.mask.type === "freeform" && handleId.kind === "anchor" + ? handleId : null; - const parsedSegmentHandle = - selectedWithMask.mask.type === "custom" - ? parseCustomMaskSegmentHandleId({ handleId }) + const segmentHandle = + selectedWithMask.mask.type === "freeform" && handleId.kind === "segment" + ? handleId : null; - if (isCreatingCustomMask) { + if (isCreatingFreeformPathMask) { const firstPointId = customMaskPointIds[0]; if ( firstPointId && - handleId === `point:${firstPointId}:anchor` && + handleId.kind === "anchor" && + handleId.pointId === firstPointId && customMaskPointIds.length >= 3 && - selectedWithMask.mask.type === "custom" + selectedWithMask.mask.type === "freeform" ) { const updatedMask = withUpdatedMaskParams({ mask: selectedWithMask.mask, @@ -390,13 +394,13 @@ export function useMaskHandles({ }); if (!pos) return; - if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") { + if (segmentHandle && selectedWithMask.mask.type === "freeform") { setActiveHandleId(handleId); pendingSegmentInsertRef.current = { trackId: selectedWithMask.trackId, elementId: selectedWithMask.elementId, maskId: selectedWithMask.mask.id, - segmentIndex: parsedSegmentHandle.segmentIndex, + segmentIndex: segmentHandle.index, startClientX: event.clientX, startClientY: event.clientY, startCanvasX: pos.x, @@ -404,7 +408,7 @@ export function useMaskHandles({ startParams: { ...selectedWithMask.mask.params }, bounds: selectedWithMask.bounds, }; - const captureTarget = event.currentTarget as HTMLElement; + const captureTarget = event.currentTarget; captureTarget.setPointerCapture(event.pointerId); captureRef.current = { element: captureTarget, @@ -413,15 +417,15 @@ export function useMaskHandles({ return; } - if (parsedHandle?.part === "anchor") { - updateCustomMaskPointSelection({ - pointId: parsedHandle.pointId, + if (anchorHandle) { + updateFreeformPathMaskPointSelection({ + pointId: anchorHandle.pointId, toggleSelection: event.shiftKey, }); if (event.shiftKey) { return; } - } else if (selectedWithMask.mask.type === "custom") { + } else if (selectedWithMask.mask.type === "freeform") { editor.selection.clearMaskPointSelection(); } @@ -434,7 +438,7 @@ export function useMaskHandles({ startParams: { ...selectedWithMask.mask.params }, }; setActiveHandleId(handleId); - const captureTarget = event.currentTarget as HTMLElement; + const captureTarget = event.currentTarget; captureTarget.setPointerCapture(event.pointerId); captureRef.current = { element: captureTarget, @@ -445,22 +449,22 @@ export function useMaskHandles({ customMaskPointIds, editor.selection, editor.timeline, - isCreatingCustomMask, + isCreatingFreeformPathMask, selectedWithMask, - updateCustomMaskPointSelection, + updateFreeformPathMaskPointSelection, viewport, ], ); const handleCanvasPointerDown = useCallback( ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithMask || !isCreatingCustomMask) { + if (!selectedWithMask || !isCreatingFreeformPathMask) { return; } if (event.button !== 0) { return; } - if (selectedWithMask.mask.type !== "custom") { + if (selectedWithMask.mask.type !== "freeform") { return; } @@ -473,7 +477,7 @@ export function useMaskHandles({ return; } - const nextParams = appendPointToCustomMask({ + const nextParams = appendPointToFreeformPathMask({ params: selectedWithMask.mask.params, canvasPoint: pos, bounds: selectedWithMask.bounds, @@ -498,7 +502,7 @@ export function useMaskHandles({ ], }); }, - [editor.timeline, isCreatingCustomMask, selectedWithMask, viewport], + [editor.timeline, isCreatingFreeformPathMask, selectedWithMask, viewport], ); const handlePointerMove = useCallback( @@ -513,13 +517,13 @@ export function useMaskHandles({ dragStateRef.current = { trackId: pendingSegmentInsert.trackId, elementId: pendingSegmentInsert.elementId, - handleId: "position", + handleId: { kind: "position" }, startCanvasX: pendingSegmentInsert.startCanvasX, startCanvasY: pendingSegmentInsert.startCanvasY, startParams: pendingSegmentInsert.startParams, }; pendingSegmentInsertRef.current = null; - setActiveHandleId("position"); + setActiveHandleId({ kind: "position" }); } } @@ -534,7 +538,7 @@ export function useMaskHandles({ const deltaX = pos.x - drag.startCanvasX; const deltaY = pos.y - drag.startCanvasY; - const def = masksRegistry.get(selectedWithMask.mask.type); + const def = getMaskDefinition(selectedWithMask.mask.type); const rawParams = def.computeParamUpdate({ handleId: drag.handleId, @@ -596,7 +600,7 @@ export function useMaskHandles({ const handlePointerUp = useCallback(() => { const pendingSegmentInsert = pendingSegmentInsertRef.current; if (pendingSegmentInsert && !dragStateRef.current) { - editor.timeline.insertCustomMaskPoint({ + editor.timeline.insertFreeformPathMaskPoint({ trackId: pendingSegmentInsert.trackId, elementId: pendingSegmentInsert.elementId, maskId: pendingSegmentInsert.maskId, @@ -623,7 +627,7 @@ export function useMaskHandles({ selectedWithMask, handlePositions, overlays, - isCreatingCustomMask, + isCreatingFreeformPathMask, handleCanvasPointerDown, activeHandleId, handlePointerDown, diff --git a/apps/web/src/media/audio.ts b/apps/web/src/media/audio.ts index 42756d49..7abaf90e 100644 --- a/apps/web/src/media/audio.ts +++ b/apps/web/src/media/audio.ts @@ -11,6 +11,7 @@ import { applyAudioMasteringToBuffer } from "@/media/audio-mastering"; import type { AudioCapableElement } from "@/timeline/audio-state"; import { hasAnimatedVolume, + isElementMuted, resolveEffectiveAudioGain, } from "@/timeline/audio-state"; import { doesElementHaveEnabledAudio } from "@/timeline/audio-separation"; @@ -128,7 +129,7 @@ export function timelineHasAudio({ mediaAssets: MediaAsset[]; }): boolean { return collectAudibleCandidates({ tracks, mediaAssets }).some( - ({ element }) => element.muted !== true, + ({ element }) => !isElementMuted({ element }), ); } @@ -168,7 +169,7 @@ export async function collectAudioElements({ trackMuted: false, localTime: 0, }), - muted: element.muted === true, + muted: isElementMuted({ element }), retime: element.retime, }; }), @@ -197,7 +198,7 @@ export async function collectAudioElements({ trackMuted: false, localTime: 0, }), - muted: element.muted ?? false, + muted: isElementMuted({ element }), retime: element.retime, }; }), @@ -501,7 +502,7 @@ export async function collectAudioMixSources({ for (const element of track.elements) { if (!canElementHaveAudio(element)) continue; - if (element.muted === true) continue; + if (isElementMuted({ element })) continue; const mediaAsset = hasMediaId(element) ? (mediaMap.get(element.mediaId) ?? null) : null; @@ -570,9 +571,7 @@ export async function collectAudioClips({ : null; if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue; - const isElementMuted = - "muted" in element ? (element.muted ?? false) : false; - const muted = isTrackMuted || isElementMuted; + const muted = isTrackMuted || isElementMuted({ element }); const volume = resolveEffectiveAudioGain({ element, trackMuted: isTrackMuted, diff --git a/apps/web/src/media/use-paste-media.ts b/apps/web/src/media/use-paste-media.ts index 3055920e..7dbd4078 100644 --- a/apps/web/src/media/use-paste-media.ts +++ b/apps/web/src/media/use-paste-media.ts @@ -7,7 +7,7 @@ import { AddMediaAssetCommand } from "@/commands/media"; import { InsertElementCommand } from "@/commands/timeline"; import { BatchCommand } from "@/commands"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds } from "@/wasm"; import { isTypableDOMElement } from "@/utils/browser"; import type { MediaType } from "@/media/types"; @@ -68,14 +68,14 @@ export function usePasteMedia() { const startTime = editor.playback.getCurrentTime(); for (const asset of processedAssets) { - const addMediaCmd = new AddMediaAssetCommand( - activeProject.metadata.id, + const addMediaCmd = new AddMediaAssetCommand({ + projectId: activeProject.metadata.id, asset, - ); + }); const assetId = addMediaCmd.getAssetId(); const duration = asset.duration != null - ? Math.round(asset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: asset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const trackType = asset.type === "audio" ? "audio" : "video"; diff --git a/apps/web/src/params/__tests__/channel-layout.test.ts b/apps/web/src/params/__tests__/channel-layout.test.ts new file mode 100644 index 00000000..e058418d --- /dev/null +++ b/apps/web/src/params/__tests__/channel-layout.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { formatLinearRgba } from "@/params"; + +describe("channel layout", () => { + test("formats linear animated colors as hex", () => { + expect(formatLinearRgba({ color: { r: 1, g: 0, b: 0, a: 1 } })).toBe( + "#ff0000", + ); + }); +}); diff --git a/apps/web/src/params/index.ts b/apps/web/src/params/index.ts index 633bfbd0..fa2550f8 100644 --- a/apps/web/src/params/index.ts +++ b/apps/web/src/params/index.ts @@ -1,50 +1,342 @@ -export type ParamValues = Record<string, number | string | boolean>;
-
-export type ParamGroup = "stroke";
-
-interface BaseParamDefinition<TKey extends string = string> {
- key: TKey;
- label: string;
- group?: ParamGroup;
-}
-
-export interface NumberParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "number";
- default: number;
- min: number;
- max?: number;
- step: number;
- /** When set, min/max/step are in display space. display = stored * displayMultiplier. */
- displayMultiplier?: number;
- /** Show as percentage of max. min/max/step/default stay in stored space. */
- unit?: "percent";
- /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */
- shortLabel?: string;
-}
-
-export interface BooleanParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "boolean";
- default: boolean;
-}
-
-export interface ColorParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "color";
- default: string;
-}
-
-export interface SelectParamDefinition<TKey extends string = string>
- extends BaseParamDefinition<TKey> {
- type: "select";
- default: string;
- options: Array<{ value: string; label: string }>;
-}
-
-export type ParamDefinition<TKey extends string = string> =
- | NumberParamDefinition<TKey>
- | BooleanParamDefinition<TKey>
- | ColorParamDefinition<TKey>
- | SelectParamDefinition<TKey>;
-
+import { converter, formatHex, formatHex8, parse } from "culori"; +import { clamp, snapToStep } from "@/utils/math"; + +export type ParamValue = number | string | boolean; +export type ParamValues = Record<string, ParamValue>; + +export type ParamGroup = "stroke"; + +export type ChannelValueKind = "scalar" | "discrete"; +export type ChannelEasingMode = "independent" | "shared"; + +export interface LinearRgba { + r: number; + g: number; + b: number; + a: number; +} + +export interface ChannelComponentDefinition<TKey extends string = string> { + key: TKey; + valueKind: ChannelValueKind; + defaultInterpolation: "linear" | "hold"; +} + +export interface LeafChannelLayout<TValue extends ParamValue = ParamValue> { + kind: "leaf"; + component: ChannelComponentDefinition<"value">; + easingMode: "independent"; + decompose: (value: TValue) => { value: TValue }; + compose: (components: { value?: TValue }) => TValue | null; +} + +export interface CompositeChannelLayout< + TValue extends ParamValue = ParamValue, + TComponents extends object = Record<string, ParamValue>, +> { + kind: "composite"; + components: Array<ChannelComponentDefinition<keyof TComponents & string>>; + easingMode: ChannelEasingMode; + decompose: (value: TValue) => TComponents | null; + compose: (components: Partial<TComponents>) => TValue | null; +} + +export type ChannelLayout< + TValue extends ParamValue = ParamValue, + TComponents extends object = Record<string, ParamValue>, +> = LeafChannelLayout<TValue> | CompositeChannelLayout<TValue, TComponents>; + +export type ParamChannelLayout = + | LeafChannelLayout<number> + | LeafChannelLayout<boolean> + | LeafChannelLayout<string> + | CompositeChannelLayout<string, LinearRgba>; + +const toRgb = converter("rgb"); + +function srgbToLinear({ value }: { value: number }): number { + return value <= 0.04045 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); +} + +function linearToSrgb({ value }: { value: number }): number { + const clamped = clamp({ value, min: 0, max: 1 }); + return clamped <= 0.0031308 + ? clamped * 12.92 + : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; +} + +export function parseColorToLinearRgba({ + color, +}: { + color: string; +}): LinearRgba | null { + const parsed = parse(color); + const rgb = parsed ? toRgb(parsed) : null; + if (!rgb) { + return null; + } + + return { + r: srgbToLinear({ value: rgb.r ?? 0 }), + g: srgbToLinear({ value: rgb.g ?? 0 }), + b: srgbToLinear({ value: rgb.b ?? 0 }), + a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }), + }; +} + +export function formatLinearRgba({ + color, +}: { + color: LinearRgba; +}): string { + const rgb: { + mode: "rgb"; + r: number; + g: number; + b: number; + alpha: number; + } = { + mode: "rgb", + r: linearToSrgb({ value: color.r }), + g: linearToSrgb({ value: color.g }), + b: linearToSrgb({ value: color.b }), + alpha: clamp({ value: color.a, min: 0, max: 1 }), + }; + return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); +} + +function createLeafChannelLayout<TValue extends ParamValue>({ + valueKind, + defaultInterpolation, +}: { + valueKind: ChannelValueKind; + defaultInterpolation: "linear" | "hold"; +}): LeafChannelLayout<TValue> { + return { + kind: "leaf", + component: { + key: "value", + valueKind, + defaultInterpolation, + }, + easingMode: "independent", + decompose: (value) => ({ value }), + compose: ({ value }) => value ?? null, + }; +} + +export const NUMBER_CHANNEL_LAYOUT: LeafChannelLayout<number> = + createLeafChannelLayout<number>({ + valueKind: "scalar", + defaultInterpolation: "linear", + }); + +export const BOOLEAN_CHANNEL_LAYOUT: LeafChannelLayout<boolean> = + createLeafChannelLayout<boolean>({ + valueKind: "discrete", + defaultInterpolation: "hold", + }); + +export const STRING_CHANNEL_LAYOUT: LeafChannelLayout<string> = + createLeafChannelLayout<string>({ + valueKind: "discrete", + defaultInterpolation: "hold", + }); + +const colorComponent = ( + key: keyof LinearRgba, +): ChannelComponentDefinition<keyof LinearRgba> => ({ + key, + valueKind: "scalar", + defaultInterpolation: "linear", +}); + +export const COLOR_CHANNEL_LAYOUT: CompositeChannelLayout<string, LinearRgba> = { + kind: "composite", + components: [ + colorComponent("r"), + colorComponent("g"), + colorComponent("b"), + colorComponent("a"), + ], + easingMode: "shared", + decompose: (value) => parseColorToLinearRgba({ color: value }), + compose: ({ r, g, b, a }) => + typeof r === "number" && + typeof g === "number" && + typeof b === "number" && + typeof a === "number" + ? formatLinearRgba({ color: { r, g, b, a } }) + : null, +}; + +interface BaseParamDefinition<TKey extends string = string> { + key: TKey; + label: string; + group?: ParamGroup; + keyframable?: boolean; + dependencies?: Array<{ param: string; equals: ParamValue }>; +} + +export interface NumberParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "number"; + default: number; + channels?: LeafChannelLayout<number>; + min: number; + max?: number; + step: number; + /** When set, min/max/step are in display space. display = stored * displayMultiplier. */ + displayMultiplier?: number; + /** Show as percentage of max. min/max/step/default stay in stored space. */ + unit?: "percent"; + /** Short label shown as the scrub handle icon in the number field (e.g. "W", "R"). */ + shortLabel?: string; +} + +export interface BooleanParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "boolean"; + default: boolean; + channels?: LeafChannelLayout<boolean>; +} + +export interface ColorParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "color"; + default: string; + channels?: ChannelLayout<string, LinearRgba>; +} + +export interface SelectParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "select"; + default: string; + channels?: LeafChannelLayout<string>; + options: Array<{ value: string; label: string }>; +} + +export interface TextParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "text"; + default: string; + channels?: LeafChannelLayout<string>; +} + +export interface FontParamDefinition<TKey extends string = string> + extends BaseParamDefinition<TKey> { + type: "font"; + default: string; + channels?: LeafChannelLayout<string>; +} + +export type ParamDefinition<TKey extends string = string> = + | NumberParamDefinition<TKey> + | BooleanParamDefinition<TKey> + | ColorParamDefinition<TKey> + | SelectParamDefinition<TKey> + | TextParamDefinition<TKey> + | FontParamDefinition<TKey>; + +export function getParamChannelLayout({ + param, +}: { + param: ParamDefinition; +}): ParamChannelLayout { + switch (param.type) { + case "number": + return param.channels ?? NUMBER_CHANNEL_LAYOUT; + case "boolean": + return param.channels ?? BOOLEAN_CHANNEL_LAYOUT; + case "color": + return param.channels ?? COLOR_CHANNEL_LAYOUT; + case "select": + case "text": + case "font": + return param.channels ?? STRING_CHANNEL_LAYOUT; + default: { + const exhaustive: never = param; + return exhaustive; + } + } +} + +export function getParamValueKind({ + param, +}: { + param: ParamDefinition; +}): "number" | "color" | "discrete" { + const layout = getParamChannelLayout({ param }); + if (layout.kind === "composite") { + return "color"; + } + if (layout.component.valueKind === "scalar") { + return "number"; + } + return "discrete"; +} + +export function getParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): "linear" | "hold" { + const layout = getParamChannelLayout({ param }); + if (layout.kind === "leaf") { + return layout.component.defaultInterpolation; + } + return layout.components[0]?.defaultInterpolation ?? "linear"; +} + +export function getParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): { min?: number; max?: number; step?: number } | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + min: param.min, + max: param.max, + step: param.step, + }; +} + +export function coerceParamValue({ + param, + value, +}: { + param: ParamDefinition; + value: unknown; +}): ParamValue | null { + switch (param.type) { + case "number": { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(param.min, steppedValue)); + } + case "boolean": + return typeof value === "boolean" ? value : null; + case "color": + case "text": + case "font": + return typeof value === "string" ? value : null; + case "select": + return typeof value === "string" && + param.options.some((option) => option.value === value) + ? value + : null; + default: { + const exhaustive: never = param; + return exhaustive; + } + } +} diff --git a/apps/web/src/params/registry.ts b/apps/web/src/params/registry.ts index d9fcc266..9e363c88 100644 --- a/apps/web/src/params/registry.ts +++ b/apps/web/src/params/registry.ts @@ -1,40 +1,438 @@ -import type { ParamDefinition, ParamValues } from "@/params";
-
-export function buildDefaultParamValues(
- params: ParamDefinition[],
-): ParamValues {
- const values: ParamValues = {};
- for (const param of params) {
- values[param.key] = param.default;
- }
- return values;
-}
-
-export class DefinitionRegistry<TKey extends string, TDefinition> {
- private definitions = new Map<TKey, TDefinition>();
- private entityName: string;
-
- constructor(entityName: string) {
- this.entityName = entityName;
- }
-
- register(key: TKey, definition: TDefinition): void {
- this.definitions.set(key, definition);
- }
-
- has(key: TKey): boolean {
- return this.definitions.has(key);
- }
-
- get(key: TKey): TDefinition {
- const def = this.definitions.get(key);
- if (!def) {
- throw new Error(`Unknown ${this.entityName}: ${key}`);
- }
- return def;
- }
-
- getAll(): TDefinition[] {
- return Array.from(this.definitions.values());
- }
-}
+import type { + ParamDefinition, + ParamValue, + ParamValues, +} from "@/params"; +import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; +import type { BlendMode } from "@/rendering"; +import type { + ElementType, + TimelineElement, +} from "@/timeline"; +import { DEFAULTS } from "@/timeline/defaults"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, +} from "@/text/background"; + +export type ElementParamDefinition<TKey extends string = string> = + ParamDefinition<TKey> & { + read?: ({ element }: { element: TimelineElement }) => ParamValue | null; + write?: ({ + element, + value, + }: { + element: TimelineElement; + value: ParamValue; + }) => TimelineElement; + }; + +export function buildDefaultParamValues( + params: readonly ParamDefinition[], +): ParamValues { + const values: ParamValues = {}; + for (const param of params) { + values[param.key] = param.default; + } + return values; +} + +export class DefinitionRegistry<TKey extends string, TDefinition> { + private definitions = new Map<TKey, TDefinition>(); + private entityName: string; + + constructor(entityName: string) { + this.entityName = entityName; + } + + register({ + key, + definition, + }: { + key: TKey; + definition: TDefinition; + }): void { + this.definitions.set(key, definition); + } + + has(key: TKey): boolean { + return this.definitions.has(key); + } + + get(key: TKey): TDefinition { + const def = this.definitions.get(key); + if (!def) { + throw new Error(`Unknown ${this.entityName}: ${key}`); + } + return def; + } + + getAll(): TDefinition[] { + return Array.from(this.definitions.values()); + } +} + +const BLEND_MODE_OPTIONS: Array<{ value: BlendMode; label: string }> = [ + { value: "normal", label: "Normal" }, + { value: "darken", label: "Darken" }, + { value: "multiply", label: "Multiply" }, + { value: "color-burn", label: "Color Burn" }, + { value: "lighten", label: "Lighten" }, + { value: "screen", label: "Screen" }, + { value: "plus-lighter", label: "Plus Lighter" }, + { value: "color-dodge", label: "Color Dodge" }, + { value: "overlay", label: "Overlay" }, + { value: "soft-light", label: "Soft Light" }, + { value: "hard-light", label: "Hard Light" }, + { value: "difference", label: "Difference" }, + { value: "exclusion", label: "Exclusion" }, + { value: "hue", label: "Hue" }, + { value: "saturation", label: "Saturation" }, + { value: "color", label: "Color" }, + { value: "luminosity", label: "Luminosity" }, +]; + +const visualElementParams: ElementParamDefinition[] = [ + { + key: "transform.positionX", + label: "Position X", + type: "number", + default: DEFAULTS.element.transform.position.x, + min: -100_000, + step: 1, + }, + { + key: "transform.positionY", + label: "Position Y", + type: "number", + default: DEFAULTS.element.transform.position.y, + min: -100_000, + step: 1, + }, + { + key: "transform.scaleX", + label: "Scale X", + type: "number", + default: DEFAULTS.element.transform.scaleX, + min: MIN_TRANSFORM_SCALE, + step: 0.01, + }, + { + key: "transform.scaleY", + label: "Scale Y", + type: "number", + default: DEFAULTS.element.transform.scaleY, + min: MIN_TRANSFORM_SCALE, + step: 0.01, + }, + { + key: "transform.rotate", + label: "Rotate", + type: "number", + default: DEFAULTS.element.transform.rotate, + min: -360, + max: 360, + step: 1, + }, + { + key: "opacity", + label: "Opacity", + type: "number", + default: DEFAULTS.element.opacity, + min: 0, + max: 1, + step: 0.01, + }, + { + key: "blendMode", + label: "Blend Mode", + type: "select", + default: DEFAULTS.element.blendMode, + keyframable: false, + options: BLEND_MODE_OPTIONS, + }, +]; + +const audioElementParams: ElementParamDefinition[] = [ + { + key: "volume", + label: "Volume", + type: "number", + default: DEFAULTS.element.volume, + min: VOLUME_DB_MIN, + max: VOLUME_DB_MAX, + step: 0.01, + }, + { + key: "muted", + label: "Muted", + type: "boolean", + default: false, + keyframable: false, + }, +]; + +const textElementParams: ElementParamDefinition[] = [ + { + key: "content", + label: "Content", + type: "text", + default: "Default text", + keyframable: false, + }, + { + key: "fontFamily", + label: "Font Family", + type: "font", + default: "Arial", + keyframable: false, + }, + { + key: "fontSize", + label: "Font Size", + type: "number", + default: 15, + min: 1, + step: 1, + }, + { + key: "color", + label: "Color", + type: "color", + default: "#ffffff", + }, + { + key: "textAlign", + label: "Text Align", + type: "select", + default: "center", + keyframable: false, + options: [ + { value: "left", label: "Left" }, + { value: "center", label: "Center" }, + { value: "right", label: "Right" }, + ], + }, + { + key: "fontWeight", + label: "Font Weight", + type: "select", + default: "normal", + keyframable: false, + options: [ + { value: "normal", label: "Normal" }, + { value: "bold", label: "Bold" }, + ], + }, + { + key: "fontStyle", + label: "Font Style", + type: "select", + default: "normal", + keyframable: false, + options: [ + { value: "normal", label: "Normal" }, + { value: "italic", label: "Italic" }, + ], + }, + { + key: "textDecoration", + label: "Text Decoration", + type: "select", + default: "none", + keyframable: false, + options: [ + { value: "none", label: "None" }, + { value: "underline", label: "Underline" }, + { value: "line-through", label: "Line Through" }, + ], + }, + { + key: "letterSpacing", + label: "Letter Spacing", + type: "number", + default: DEFAULTS.text.letterSpacing, + min: -100, + step: 0.1, + }, + { + key: "lineHeight", + label: "Line Height", + type: "number", + default: DEFAULTS.text.lineHeight, + min: 0.1, + step: 0.1, + }, + { + key: "background.enabled", + label: "Background Enabled", + type: "boolean", + default: DEFAULTS.text.background.enabled, + keyframable: false, + }, + { + key: "background.color", + label: "Background Color", + type: "color", + default: DEFAULTS.text.background.color, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.cornerRadius", + label: "Background Radius", + type: "number", + default: DEFAULTS.text.background.cornerRadius, + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.paddingX", + label: "Background Padding X", + type: "number", + default: DEFAULTS.text.background.paddingX, + min: 0, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.paddingY", + label: "Background Padding Y", + type: "number", + default: DEFAULTS.text.background.paddingY, + min: 0, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.offsetX", + label: "Background Offset X", + type: "number", + default: DEFAULTS.text.background.offsetX, + min: -100_000, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, + { + key: "background.offsetY", + label: "Background Offset Y", + type: "number", + default: DEFAULTS.text.background.offsetY, + min: -100_000, + step: 1, + dependencies: [{ param: "background.enabled", equals: true }], + }, +]; + +export const elementParamRegistry = new DefinitionRegistry< + ElementType, + readonly ElementParamDefinition[] +>("element params"); + +elementParamRegistry.register({ + key: "video", + definition: [...visualElementParams, ...audioElementParams], +}); +elementParamRegistry.register({ key: "image", definition: visualElementParams }); +elementParamRegistry.register({ + key: "text", + definition: [...textElementParams, ...visualElementParams], +}); +elementParamRegistry.register({ + key: "sticker", + definition: visualElementParams, +}); +elementParamRegistry.register({ + key: "graphic", + definition: visualElementParams, +}); +elementParamRegistry.register({ key: "audio", definition: audioElementParams }); +elementParamRegistry.register({ key: "effect", definition: [] }); + +export function getElementParams({ + element, +}: { + element: TimelineElement; +}): readonly ElementParamDefinition[] { + return elementParamRegistry.has(element.type) + ? elementParamRegistry.get(element.type) + : []; +} + +export function getBuiltInElementParams({ + type, +}: { + type: ElementType; +}): readonly ElementParamDefinition[] { + return elementParamRegistry.has(type) ? elementParamRegistry.get(type) : []; +} + +export function getElementParam({ + element, + key, +}: { + element: TimelineElement; + key: string; +}): ElementParamDefinition | null { + return ( + getElementParams({ element }).find((param) => param.key === key) ?? null + ); +} + +export function readElementParamValue({ + element, + param, +}: { + element: TimelineElement; + param: ElementParamDefinition; +}): ParamValue | null { + if (param.read) { + return param.read({ element }); + } + if ("params" in element) { + return element.params[param.key] ?? param.default; + } + return null; +} + +export function writeElementParamValue({ + element, + param, + value, +}: { + element: TimelineElement; + param: ElementParamDefinition; + value: ParamValue; +}): TimelineElement { + if (param.write) { + return param.write({ element, value }); + } + if ("params" in element) { + return { + ...element, + params: { + ...element.params, + [param.key]: value, + }, + }; + } + return element; +} + +export function buildElementParamValues({ + element, +}: { + element: TimelineElement; +}): ParamValues { + const values: ParamValues = {}; + for (const param of getElementParams({ element })) { + const value = readElementParamValue({ element, param }); + if (value !== null) { + values[param.key] = value; + } + } + return values; +} + diff --git a/apps/web/src/preview/components/context-menu.tsx b/apps/web/src/preview/components/context-menu.tsx index fca55d71..a0f1bc5c 100644 --- a/apps/web/src/preview/components/context-menu.tsx +++ b/apps/web/src/preview/components/context-menu.tsx @@ -13,12 +13,12 @@ import { toast } from "sonner"; export function PreviewContextMenu({ onToggleFullscreen, - containerRef, + container, overlayControls, onOverlayVisibilityChange, }: { onToggleFullscreen: () => void; - containerRef: React.RefObject<HTMLElement | null>; + container: HTMLElement | null; overlayControls: PreviewOverlayControl[]; onOverlayVisibilityChange: (params: { overlayId: string; @@ -51,7 +51,7 @@ export function PreviewContextMenu({ }; return ( - <ContextMenuContent className="w-56" container={containerRef.current}> + <ContextMenuContent className="w-56" container={container}> <ContextMenuItem onClick={viewport.fitToScreen} inset> Fit to screen </ContextMenuItem> diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx index e8f5c572..894eec78 100644 --- a/apps/web/src/preview/components/index.tsx +++ b/apps/web/src/preview/components/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useDeepCompareEffect from "use-deep-compare-effect"; import { useEditor } from "@/editor/use-editor"; import { useRafLoop } from "@/hooks/use-raf-loop"; @@ -68,15 +68,20 @@ export function PreviewPanel({ }) => void; }) { const containerRef = useRef<HTMLDivElement>(null); + const [container, setContainer] = useState<HTMLDivElement | null>(null); const { toggleFullscreen } = useFullscreen({ containerRef }); + const handleContainerRef = useCallback((node: HTMLDivElement | null) => { + containerRef.current = node; + setContainer(node); + }, []); return ( <div - ref={containerRef} + ref={handleContainerRef} className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border" > <PreviewCanvas - containerRef={containerRef} + container={container} onToggleFullscreen={toggleFullscreen} overlayControls={overlayControls} overlayInstances={overlayInstances} @@ -117,13 +122,13 @@ function RenderTreeController() { } function PreviewCanvas({ - containerRef, + container, onToggleFullscreen, overlayControls, overlayInstances, onOverlayVisibilityChange, }: { - containerRef: React.RefObject<HTMLElement | null>; + container: HTMLElement | null; onToggleFullscreen: () => void; overlayControls: PreviewOverlayControl[]; overlayInstances: PreviewOverlayInstance[]; @@ -149,6 +154,7 @@ function PreviewCanvas({ viewportRef, viewportWidth: viewportSize.width, }); + const { canPan, panByScreenDelta, scaleZoom } = viewport; const renderer = useMemo(() => { return new CanvasRenderer({ @@ -240,7 +246,7 @@ function PreviewCanvas({ Math.min(Math.abs(pendingZoomDelta), 30); const zoomFactor = Math.exp(-cappedDelta / 300); - viewport.scaleZoom({ factor: zoomFactor }); + scaleZoom({ factor: zoomFactor }); pendingZoomDelta = 0; zoomRafId = null; }); @@ -249,7 +255,7 @@ function PreviewCanvas({ return; } - if (!viewport.canPan) { + if (!canPan) { return; } @@ -263,7 +269,7 @@ function PreviewCanvas({ if (panRafId === null) { panRafId = requestAnimationFrame(() => { - viewport.panByScreenDelta({ + panByScreenDelta({ deltaX: pendingPanDeltaX, deltaY: pendingPanDeltaY, }); @@ -290,7 +296,7 @@ function PreviewCanvas({ cancelAnimationFrame(panRafId); } }; - }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]); + }, [canPan, panByScreenDelta, scaleZoom]); return ( <PreviewViewportProvider value={viewport}> @@ -329,7 +335,7 @@ function PreviewCanvas({ </ContextMenuTrigger> <PreviewContextMenu onToggleFullscreen={onToggleFullscreen} - containerRef={containerRef} + container={container} overlayControls={overlayControls} onOverlayVisibilityChange={onOverlayVisibilityChange} /> diff --git a/apps/web/src/preview/components/mask-handles.tsx b/apps/web/src/preview/components/mask-handles.tsx index 340cab0a..314984ef 100644 --- a/apps/web/src/preview/components/mask-handles.tsx +++ b/apps/web/src/preview/components/mask-handles.tsx @@ -3,6 +3,7 @@ import { PEN_CURSOR } from "@/preview/components/cursors"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import { useMaskHandles } from "@/masks/use-mask-handles"; +import { maskHandleIdKey, type MaskHandleId } from "@/masks/types"; import type { SnapLine } from "@/preview/preview-snap"; import { CornerHandle, @@ -16,7 +17,6 @@ import { } from "./handle-primitives"; const CUSTOM_MASK_ANCHOR_SIZE = 7; -const CUSTOM_MASK_TANGENT_SIZE = 6; import { Rotate01Icon, FeatherIcon } from "@hugeicons/core-free-icons"; export function MaskHandles({ @@ -29,7 +29,7 @@ export function MaskHandles({ selectedWithMask, handlePositions, overlays, - isCreatingCustomMask, + isCreatingFreeformPathMask, handleCanvasPointerDown, handlePointerDown, handlePointerMove, @@ -72,7 +72,7 @@ export function MaskHandles({ handleId, }: { event: React.PointerEvent; - handleId: string; + handleId: MaskHandleId; }) => { if (viewport.handlePanPointerDown({ event })) { return; @@ -93,7 +93,7 @@ export function MaskHandles({ className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden > - {isCreatingCustomMask ? ( + {isCreatingFreeformPathMask ? ( <div className="absolute inset-0 pointer-events-auto" style={{ cursor: PEN_CURSOR }} @@ -193,11 +193,12 @@ export function MaskHandles({ })} {handlePositions.map((handle) => { const screen = toOverlay({ canvasX: handle.x, canvasY: handle.y }); + const key = maskHandleIdKey({ id: handle.id }); if (handle.kind === "icon" && handle.icon === "rotate") { return ( <IconHandle - key={handle.id} + key={key} icon={Rotate01Icon} screen={screen} onPointerDown={(event) => @@ -212,7 +213,7 @@ export function MaskHandles({ if (handle.kind === "icon" && handle.icon === "feather") { return ( <IconHandle - key={handle.id} + key={key} icon={FeatherIcon} screen={screen} onPointerDown={(event) => @@ -227,7 +228,7 @@ export function MaskHandles({ if (handle.kind === "edge" && handle.edgeAxis === "horizontal") { return ( <EdgeHandle - key={handle.id} + key={key} edge="right" screen={screen} rotation={handle.rotation ?? 0} @@ -243,7 +244,7 @@ export function MaskHandles({ if (handle.kind === "edge" && handle.edgeAxis === "vertical") { return ( <EdgeHandle - key={handle.id} + key={key} edge="bottom" screen={screen} rotation={handle.rotation ?? 0} @@ -256,30 +257,26 @@ export function MaskHandles({ ); } - if (handle.kind === "point" || handle.kind === "tangent") { - return ( - <CircleHandle - key={handle.id} - screen={screen} - size={ - handle.kind === "tangent" - ? CUSTOM_MASK_TANGENT_SIZE - : CUSTOM_MASK_ANCHOR_SIZE - } - isSelected={handle.isSelected} - onPointerDown={(event) => - handleMaskPointerDown({ event, handleId: handle.id }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - } + if (handle.kind === "point") { + return ( + <CircleHandle + key={key} + screen={screen} + size={CUSTOM_MASK_ANCHOR_SIZE} + isSelected={handle.isSelected} + onPointerDown={(event) => + handleMaskPointerDown({ event, handleId: handle.id }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + } if (handle.kind === "corner") { return ( <CornerHandle - key={handle.id} + key={key} screen={screen} onPointerDown={(event) => handleMaskPointerDown({ event, handleId: handle.id }) @@ -292,7 +289,7 @@ export function MaskHandles({ return ( <CornerHandle - key={handle.id} + key={key} cursor={handle.cursor} screen={screen} onPointerDown={(event) => diff --git a/apps/web/src/preview/components/preview-viewport.tsx b/apps/web/src/preview/components/preview-viewport.tsx index 6dc9f084..be261adc 100644 --- a/apps/web/src/preview/components/preview-viewport.tsx +++ b/apps/web/src/preview/components/preview-viewport.tsx @@ -9,6 +9,7 @@ import { useRef,
useState,
} from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import {
canvasToOverlay,
getDisplayScale,
@@ -219,8 +220,7 @@ export function usePreviewViewportState({ }));
const [isPanning, setIsPanning] = useState(false);
const panSessionRef = useRef<PanSession | null>(null);
- const centerRef = useRef(center);
- centerRef.current = center;
+ const centerRef = useCommittedRef(center);
const fitScale = useMemo(
() =>
@@ -409,10 +409,10 @@ export function usePreviewViewportState({ pointerId: event.pointerId,
};
setIsPanning(true);
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ event.currentTarget.setPointerCapture(event.pointerId);
return true;
},
- [zoom],
+ [centerRef, zoom],
);
const handlePanPointerMove = useCallback(
@@ -451,13 +451,9 @@ export function usePreviewViewportState({ }
if (
- (event.currentTarget as HTMLElement).hasPointerCapture(
- panSession.pointerId,
- )
+ event.currentTarget.hasPointerCapture(panSession.pointerId)
) {
- (event.currentTarget as HTMLElement).releasePointerCapture(
- panSession.pointerId,
- );
+ event.currentTarget.releasePointerCapture(panSession.pointerId);
}
panSessionRef.current = null;
diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx index 1d599b3b..c06d50ed 100644 --- a/apps/web/src/preview/components/text-edit-overlay.tsx +++ b/apps/web/src/preview/components/text-edit-overlay.tsx @@ -9,7 +9,12 @@ import { getElementLocalTime, } from "@/animation"; import { resolveTransformAtTime } from "@/rendering/animation-values"; +import { buildTransformFromParams } from "@/rendering"; import { resolveTextLayout } from "@/text/primitives"; +import { + buildTextBackgroundFromElement, + buildTextLayoutParamsFromElement, +} from "@/text/measure-element"; export function TextEditOverlay({ trackId, @@ -42,7 +47,7 @@ export function TextEditOverlay({ if (!div) return; const text = div.innerText; editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: { content: text } }], + updates: [{ trackId, elementId, updates: { params: { content: text } } }], }); }, [editor.timeline, trackId, elementId]); @@ -69,7 +74,7 @@ export function TextEditOverlay({ elementDuration: element.duration, }); const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -80,26 +85,17 @@ export function TextEditOverlay({ }); const { x: displayScaleX } = viewport.getDisplayScale(); + const textParams = buildTextLayoutParamsFromElement({ element }); const resolvedTextLayout = resolveTextLayout({ - text: { - content: element.content, - fontSize: element.fontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textAlign: element.textAlign, - textDecoration: element.textDecoration, - letterSpacing: element.letterSpacing, - lineHeight: element.lineHeight, - }, + text: textParams, canvasHeight: canvasSize.height, }); - const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; - const canvasLetterSpacing = element.letterSpacing ?? 0; + const lineHeight = textParams.lineHeight ?? DEFAULTS.text.lineHeight; + const canvasLetterSpacing = textParams.letterSpacing ?? 0; const lineHeightPx = resolvedTextLayout.lineHeightPx; - const bg = element.background; + const bg = buildTextBackgroundFromElement({ element }); const shouldShowBackground = bg.enabled && bg.color && bg.color !== "transparent"; const fontSizeRatio = resolvedTextLayout.fontSizeRatio; @@ -120,7 +116,6 @@ export function TextEditOverlay({ transformOrigin: "center center", }} > - {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} <div ref={divRef} contentEditable @@ -131,17 +126,20 @@ export function TextEditOverlay({ className="cursor-text select-text outline-none whitespace-pre" style={{ fontSize: resolvedTextLayout.scaledFontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight === "bold" ? "bold" : "normal", - fontStyle: element.fontStyle === "italic" ? "italic" : "normal", - textAlign: element.textAlign, + fontFamily: textParams.fontFamily, + fontWeight: textParams.fontWeight === "bold" ? "bold" : "normal", + fontStyle: textParams.fontStyle === "italic" ? "italic" : "normal", + textAlign: textParams.textAlign, letterSpacing: `${canvasLetterSpacing}px`, lineHeight, color: "transparent", - caretColor: element.color, + caretColor: + typeof element.params.color === "string" + ? element.params.color + : "#ffffff", backgroundColor: shouldShowBackground ? bg.color : "transparent", minHeight: lineHeightPx, - textDecoration: element.textDecoration ?? "none", + textDecoration: textParams.textDecoration ?? "none", padding: shouldShowBackground ? `${canvasPaddingY}px ${canvasPaddingX}px` : 0, @@ -151,7 +149,7 @@ export function TextEditOverlay({ onBlur={onCommit} onKeyDown={(event) => handleKeyDown({ event })} > - {element.content || ""} + {textParams.content} </div> </div> ); diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx index 783dd239..1a5ce1bc 100644 --- a/apps/web/src/preview/components/toolbar.tsx +++ b/apps/web/src/preview/components/toolbar.tsx @@ -22,6 +22,9 @@ import { } from "@/components/ui/select"; import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom"; import { usePreviewViewport } from "./preview-viewport"; +import { GridPopover } from "./guide-popover"; +import { usePreviewStore } from "@/preview/preview-store"; +import type { MediaTime } from "@/wasm"; export function PreviewToolbar({ onToggleFullscreen, @@ -60,7 +63,7 @@ function TimecodeDisplay() { const editor = useEditor(); const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); const fps = useEditor((e) => e.project.getActive().settings.fps); - const [currentTime, setCurrentTime] = useState(() => + const [currentTime, setCurrentTime] = useState<MediaTime>(() => editor.playback.getCurrentTime(), ); diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts index 00573662..118b48ae 100644 --- a/apps/web/src/preview/controllers/preview-interaction-controller.ts +++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts @@ -18,7 +18,8 @@ import { type SnapLine, } from "@/preview/preview-snap"; import type { TCanvasSize } from "@/project/types"; -import type { Transform } from "@/rendering"; +import type { ParamValues } from "@/params"; +import { buildTransformFromParams, type Transform } from "@/rendering"; import { isVisualElement } from "@/timeline/element-utils"; import type { ElementRef, @@ -51,6 +52,7 @@ interface DragElementSnapshot { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; } interface DraggingGesture extends CapturedPointerState { @@ -218,7 +220,8 @@ function toDragElementSnapshots({ .map(({ track, element }) => ({ trackId: track.id, elementId: element.id, - initialTransform: element.transform, + initialTransform: buildTransformFromParams({ params: element.params }), + initialParams: element.params, })); } @@ -565,16 +568,14 @@ export class PreviewInteractionController { snappedPosition.y - firstElement.initialTransform.position.y; this.deps.timeline.previewElements( - drag.elements.map(({ trackId, elementId, initialTransform }) => ({ + drag.elements.map(({ trackId, elementId, initialTransform, initialParams }) => ({ trackId, elementId, updates: { - transform: { - ...initialTransform, - position: { - x: initialTransform.position.x + deltaSnappedX, - y: initialTransform.position.y + deltaSnappedY, - }, + params: { + ...initialParams, + "transform.positionX": initialTransform.position.x + deltaSnappedX, + "transform.positionY": initialTransform.position.y + deltaSnappedY, }, }, })), diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts index 67f677f6..d9b5ef1f 100644 --- a/apps/web/src/preview/controllers/transform-handle-controller.ts +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -23,7 +23,8 @@ import { setChannel, } from "@/animation"; import type { ElementAnimations } from "@/animation/types"; -import type { Transform } from "@/rendering"; +import type { ParamValues } from "@/params"; +import { buildTransformFromParams, type Transform } from "@/rendering"; import { resolveTransformAtTime } from "@/rendering/animation-values"; import type { ElementRef, @@ -47,6 +48,7 @@ interface CornerScaleSession extends CapturedPointerState { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; readonly initialDistance: number; readonly initialBoundsCx: number; readonly initialBoundsCy: number; @@ -62,6 +64,7 @@ interface EdgeScaleSession extends CapturedPointerState { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; readonly initialBoundsCx: number; readonly initialBoundsCy: number; readonly baseWidth: number; @@ -76,6 +79,7 @@ interface RotationSession extends CapturedPointerState { readonly trackId: string; readonly elementId: string; readonly initialTransform: Transform; + readonly initialParams: ParamValues; readonly initialAngle: number; readonly initialBoundsCx: number; readonly initialBoundsCy: number; @@ -369,6 +373,7 @@ export class TransformHandleController { trackId: context.trackId, elementId: context.elementId, initialTransform: context.resolvedTransform, + initialParams: context.element.params, initialDistance: getCornerDistance({ bounds: context.bounds, corner, @@ -410,6 +415,7 @@ export class TransformHandleController { trackId: context.trackId, elementId: context.elementId, initialTransform: context.resolvedTransform, + initialParams: context.element.params, initialAngle, initialBoundsCx: context.bounds.cx, initialBoundsCy: context.bounds.cy, @@ -447,6 +453,7 @@ export class TransformHandleController { trackId: context.trackId, elementId: context.elementId, initialTransform: context.resolvedTransform, + initialParams: context.element.params, initialBoundsCx: context.bounds.cx, initialBoundsCy: context.bounds.cy, baseWidth: context.bounds.width / context.resolvedTransform.scaleX, @@ -561,7 +568,9 @@ export class TransformHandleController { element: selectedWithBounds.element, bounds: selectedWithBounds.bounds, resolvedTransform: resolveTransformAtTime({ - baseTransform: selectedWithBounds.element.transform, + baseTransform: buildTransformFromParams({ + params: selectedWithBounds.element.params, + }), animations: selectedWithBounds.element.animations, localTime, }), @@ -608,15 +617,18 @@ export class TransformHandleController { trackId: session.trackId, elementId: session.elementId, updates: { - transform: { - ...session.initialTransform, - scaleX: clampScaleNonZero( - session.initialTransform.scaleX * snappedScale, - ), - scaleY: clampScaleNonZero( - session.initialTransform.scaleY * snappedScale, - ), - }, + params: buildParamsWithTransform({ + params: session.initialParams, + transform: { + ...session.initialTransform, + scaleX: clampScaleNonZero( + session.initialTransform.scaleX * snappedScale, + ), + scaleY: clampScaleNonZero( + session.initialTransform.scaleY * snappedScale, + ), + }, + }), ...(session.shouldClearScaleAnimation && { animations: session.animationsWithoutScale, }), @@ -699,17 +711,20 @@ export class TransformHandleController { trackId: session.trackId, elementId: session.elementId, updates: { - transform: { - ...session.initialTransform, - scaleX: - session.edge === "right" || session.edge === "left" - ? xSnap.snappedScale - : session.initialTransform.scaleX, - scaleY: - session.edge === "bottom" - ? ySnap.snappedScale - : session.initialTransform.scaleY, - }, + params: buildParamsWithTransform({ + params: session.initialParams, + transform: { + ...session.initialTransform, + scaleX: + session.edge === "right" || session.edge === "left" + ? xSnap.snappedScale + : session.initialTransform.scaleX, + scaleY: + session.edge === "bottom" + ? ySnap.snappedScale + : session.initialTransform.scaleY, + }, + }), ...(session.shouldClearScaleAnimation && { animations: session.animationsWithoutScale, }), @@ -742,12 +757,32 @@ export class TransformHandleController { trackId: session.trackId, elementId: session.elementId, updates: { - transform: { - ...session.initialTransform, - rotate: snappedRotation, - }, + params: buildParamsWithTransform({ + params: session.initialParams, + transform: { + ...session.initialTransform, + rotate: snappedRotation, + }, + }), }, }, ]); } } + +function buildParamsWithTransform({ + params, + transform, +}: { + params: ParamValues; + transform: Transform; +}): ParamValues { + return { + ...params, + "transform.positionX": transform.position.x, + "transform.positionY": transform.position.y, + "transform.scaleX": transform.scaleX, + "transform.scaleY": transform.scaleY, + "transform.rotate": transform.rotate, + }; +} diff --git a/apps/web/src/preview/element-bounds.ts b/apps/web/src/preview/element-bounds.ts index 0afc6e4e..181fdf1f 100644 --- a/apps/web/src/preview/element-bounds.ts +++ b/apps/web/src/preview/element-bounds.ts @@ -7,6 +7,7 @@ import { getElementLocalTime, } from "@/animation"; import { resolveTransformAtTime } from "@/rendering/animation-values"; +import { buildTransformFromParams } from "@/rendering"; export interface ElementBounds { cx: number; @@ -123,7 +124,7 @@ function getElementBounds({ if (element.type === "video" || element.type === "image") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -140,7 +141,7 @@ function getElementBounds({ if (element.type === "sticker") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -155,7 +156,7 @@ function getElementBounds({ if (element.type === "graphic") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); @@ -170,7 +171,7 @@ function getElementBounds({ if (element.type === "text") { const transform = resolveTransformAtTime({ - baseTransform: element.transform, + baseTransform: buildTransformFromParams({ params: element.params }), animations: element.animations, localTime, }); diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts index d093dcf2..b707f1e6 100644 --- a/apps/web/src/preview/hooks/use-preview-interaction.ts +++ b/apps/web/src/preview/hooks/use-preview-interaction.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import type { SnapLine } from "@/preview/preview-snap"; @@ -59,17 +60,10 @@ export function usePreviewInteraction({ onSnapLinesChange, }, }; - - const depsRef = useRef<PreviewInteractionDeps>(deps); - depsRef.current = deps; - - const controllerRef = useRef<PreviewInteractionController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new PreviewInteractionController({ - depsRef: depsRef as PreviewInteractionDepsRef, - }); - } - const controller = controllerRef.current; + const depsRef = useCommittedRef(deps) as PreviewInteractionDepsRef; + const [controller] = useState( + () => new PreviewInteractionController({ depsRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect( diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts index f4b7e306..55d8d8f7 100644 --- a/apps/web/src/preview/hooks/use-transform-handles.ts +++ b/apps/web/src/preview/hooks/use-transform-handles.ts @@ -1,7 +1,8 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { registerCanceller } from "@/editor/cancel-interaction"; import { @@ -48,15 +49,10 @@ export function useTransformHandles({ onSnapLinesChange, }, }; - - const depsRef = useRef<TransformHandleDeps>(deps); - depsRef.current = deps; - - const controllerRef = useRef<TransformHandleController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new TransformHandleController({ depsRef }); - } - const controller = controllerRef.current; + const depsRef = useCommittedRef(deps); + const [controller] = useState( + () => new TransformHandleController({ depsRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/project/types.ts b/apps/web/src/project/types.ts index 42e844f6..dca2d785 100644 --- a/apps/web/src/project/types.ts +++ b/apps/web/src/project/types.ts @@ -1,5 +1,6 @@ import type { FrameRate } from "opencut-wasm"; import type { TScene } from "@/timeline/types"; +import type { MediaTime } from "@/wasm"; export type TBackground = | { @@ -20,7 +21,7 @@ export interface TProjectMetadata { id: string; name: string; thumbnail?: string; - duration: number; + duration: MediaTime; createdAt: Date; updatedAt: Date; } @@ -37,7 +38,7 @@ export interface TProjectSettings { export interface TTimelineViewState { zoomLevel: number; scrollLeft: number; - playheadTime: number; + playheadTime: MediaTime; } export interface TProject { diff --git a/apps/web/src/rendering/components/blending-tab.tsx b/apps/web/src/rendering/components/blending-tab.tsx deleted file mode 100644 index 48b1d0e5..00000000 --- a/apps/web/src/rendering/components/blending-tab.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { useEditor } from "@/editor/use-editor"; -import { clamp } from "@/utils/math"; -import { NumberField } from "@/components/ui/number-field"; -import { OcCheckerboardIcon } from "@/components/icons"; -import { Fragment, useRef } from "react"; -import { useMenuPreview } from "@/editor/use-menu-preview"; -import { - Section, - SectionContent, - SectionField, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { BlendMode } from "@/rendering"; -import type { ElementType } from "@/timeline"; -import type { ElementAnimations } from "@/animation/types"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { RainDropIcon } from "@hugeicons/core-free-icons"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/animation/values"; -import { DEFAULTS } from "@/timeline/defaults"; -import { isPropertyAtDefault } from "./transform-tab"; - -type BlendingElement = { - id: string; - opacity: number; - type: ElementType; - blendMode?: BlendMode; - startTime: number; - duration: number; - animations?: ElementAnimations; -}; - -const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [ - [{ value: "normal", label: "Normal" }], - [ - { value: "darken", label: "Darken" }, - { value: "multiply", label: "Multiply" }, - { value: "color-burn", label: "Color Burn" }, - ], - [ - { value: "lighten", label: "Lighten" }, - { value: "screen", label: "Screen" }, - { value: "plus-lighter", label: "Plus Lighter" }, - { value: "color-dodge", label: "Color Dodge" }, - ], - [ - { value: "overlay", label: "Overlay" }, - { value: "soft-light", label: "Soft Light" }, - { value: "hard-light", label: "Hard Light" }, - ], - [ - { value: "difference", label: "Difference" }, - { value: "exclusion", label: "Exclusion" }, - ], - [ - { value: "hue", label: "Hue" }, - { value: "saturation", label: "Saturation" }, - { value: "color", label: "Color" }, - { value: "luminosity", label: "Luminosity" }, - ], -]; - -export function BlendingTab({ - element, - trackId, -}: { - element: BlendingElement; - trackId: string; -}) { - const editor = useEditor(); - const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); - const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; - const committedBlendModeRef = useRef(blendMode); - if (!isPreviewActive) { - committedBlendModeRef.current = blendMode; - } - - const { - onPointerLeave, - onOpenChange: handleBlendModeOpenChange, - markCommitted, - } = useMenuPreview(); - - const previewBlendMode = ({ value }: { value: BlendMode }) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { blendMode: value } }, - ], - }); - - const commitBlendMode = (value: BlendMode) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { blendMode: value }, - }, - ], - }); - } - markCommitted(); - }; - - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedOpacity = resolveOpacityAtTime({ - baseOpacity: element.opacity, - animations: element.animations, - localTime, - }); - - const opacity = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "opacity", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOpacity * 100).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min: 0, max: 100 }) / 100; - }, - valueAtPlayhead: resolvedOpacity, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ opacity: value }), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:blending`}> - <SectionHeader> - <SectionTitle>Blending</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField - label="Opacity" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={opacity.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle opacity keyframe" - onToggle={opacity.toggleKeyframe} - /> - } - > - <NumberField - className="w-full" - icon={ - <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> - } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => - opacity.commitValue({ value: DEFAULTS.element.opacity }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOpacity, - staticValue: element.opacity, - defaultValue: DEFAULTS.element.opacity, - })} - dragSensitivity="slow" - /> - </SectionField> - <SectionField label="Blend mode" className="w-1/2"> - <Select - value={committedBlendModeRef.current} - onOpenChange={handleBlendModeOpenChange} - onValueChange={commitBlendMode} - > - <SelectTrigger - icon={<HugeiconsIcon icon={RainDropIcon} />} - className="w-full" - > - <SelectValue placeholder="Select blend mode" /> - </SelectTrigger> - <SelectContent className="w-36" onPointerLeave={onPointerLeave}> - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> - {group.map((option) => ( - <SelectItem - key={option.value} - value={option.value} - onPointerEnter={() => - previewBlendMode({ value: option.value }) - } - > - {option.label} - </SelectItem> - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - <SelectSeparator /> - ) : null} - </Fragment> - ))} - </SelectContent> - </Select> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/rendering/components/transform-tab.tsx b/apps/web/src/rendering/components/transform-tab.tsx deleted file mode 100644 index f4d5a183..00000000 --- a/apps/web/src/rendering/components/transform-tab.tsx +++ /dev/null @@ -1,462 +0,0 @@ -import { NumberField } from "@/components/ui/number-field"; -import { useEditor } from "@/editor/use-editor"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { VisualElement } from "@/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowExpandIcon, - Link05Icon, - RotateClockwiseIcon, -} from "@hugeicons/core-free-icons"; -import { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, -} from "@/animation"; -import { resolveTransformAtTime } from "@/rendering/animation-values"; -import { DEFAULTS } from "@/timeline/defaults"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; - -export function parseNumericInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : parsed; -} - -export function isPropertyAtDefault({ - hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue, - staticValue, - defaultValue, -}: { - hasAnimatedKeyframes: boolean; - isPlayheadWithinElementRange: boolean; - resolvedValue: number; - staticValue: number; - defaultValue: number; -}): boolean { - if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { - return isNearlyEqual({ - leftValue: resolvedValue, - rightValue: defaultValue, - }); - } - - return staticValue === defaultValue; -} - -export function TransformTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const editor = useEditor(); - const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); - const setTransformScaleLocked = usePropertiesStore( - (s) => s.setTransformScaleLocked, - ); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const positionX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.positionX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.x).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.x, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { ...element.transform.position, x: value }, - }, - }), - }); - - const positionY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.positionY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.position.y).toString(), - parse: (input) => parseNumericInput({ input }), - valueAtPlayhead: resolvedTransform.position.y, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - position: { ...element.transform.position, y: value }, - }, - }), - }); - - const parseScale = (input: string) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return parsed / 100; - }; - - const scaleX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleX, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleX: value, - ...(isScaleLocked ? { scaleY: value } : {}), - }, - }), - buildAdditionalKeyframes: isScaleLocked - ? ({ value }) => [{ propertyPath: "transform.scaleY", value }] - : undefined, - }); - - const scaleY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleY, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleY: value, - ...(isScaleLocked ? { scaleX: value } : {}), - }, - }), - buildAdditionalKeyframes: isScaleLocked - ? ({ value }) => [{ propertyPath: "transform.scaleX", value }] - : undefined, - }); - - const scaleFieldPropsX = { - value: scaleX.displayValue, - onFocus: scaleX.onFocus, - onChange: scaleX.onChange, - onBlur: scaleX.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleX.scrubTo, - onScrubEnd: scaleX.commitScrub, - onReset: () => - scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleX, - staticValue: element.transform.scaleX, - defaultValue: DEFAULTS.element.transform.scaleX, - }), - }; - - const scaleFieldPropsY = { - value: scaleY.displayValue, - onFocus: scaleY.onFocus, - onChange: scaleY.onChange, - onBlur: scaleY.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleY.scrubTo, - onScrubEnd: scaleY.commitScrub, - onReset: () => - scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleY, - staticValue: element.transform.scaleY, - defaultValue: DEFAULTS.element.transform.scaleY, - }), - }; - - const rotation = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.rotate", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.rotate).toString(), - parse: (input) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return clamp({ value: parsed, min: -360, max: 360 }); - }, - valueAtPlayhead: resolvedTransform.rotate, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - rotate: value, - }, - }), - }); - - const hasScaleKeyframe = hasGroupKeyframeAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - - const toggleScaleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - const existing = getGroupKeyframesAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - if (existing.length > 0) { - editor.timeline.removeKeyframes({ - keyframes: existing.map((ref) => ({ - trackId, - elementId: element.id, - ...ref, - })), - }); - return; - } - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleX", - time: localTime, - value: resolvedTransform.scaleX, - }, - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleY", - time: localTime, - value: resolvedTransform.scaleY, - }, - ], - }); - }; - - const scaleLockButton = ( - <Button - type="button" - variant={isScaleLocked ? "secondary" : "ghost"} - size="icon" - aria-pressed={isScaleLocked} - onClick={() => setTransformScaleLocked(!isScaleLocked)} - > - <HugeiconsIcon icon={Link05Icon} /> - </Button> - ); - - return ( - <Section collapsible sectionKey={`${element.id}:transform`}> - <SectionHeader> - <SectionTitle>Transform</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <div className="flex items-end gap-2"> - {isScaleLocked ? ( - <> - <SectionField - label="Scale" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={hasScaleKeyframe} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle scale keyframe" - onToggle={toggleScaleKeyframe} - /> - } - > - <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldPropsX} - /> - </SectionField> - {scaleLockButton} - </> - ) : ( - <> - <SectionField - label="Width" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle width scale keyframe" - onToggle={scaleX.toggleKeyframe} - /> - } - > - <NumberField icon="W" {...scaleFieldPropsX} /> - </SectionField> - {scaleLockButton} - <SectionField - label="Height" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle height scale keyframe" - onToggle={scaleY.toggleKeyframe} - /> - } - > - <NumberField icon="H" {...scaleFieldPropsY} /> - </SectionField> - </> - )} - </div> - <div className="flex items-end gap-2"> - <SectionField - label="X" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={positionX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle X position keyframe" - onToggle={positionX.toggleKeyframe} - /> - } - > - <NumberField - icon="X" - value={positionX.displayValue} - onFocus={positionX.onFocus} - onChange={positionX.onChange} - onBlur={positionX.onBlur} - onScrub={positionX.scrubTo} - onScrubEnd={positionX.commitScrub} - onReset={() => - positionX.commitValue({ - value: DEFAULTS.element.transform.position.x, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.x, - staticValue: element.transform.position.x, - defaultValue: DEFAULTS.element.transform.position.x, - })} - /> - </SectionField> - <SectionField - label="Y" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={positionY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle Y position keyframe" - onToggle={positionY.toggleKeyframe} - /> - } - > - <NumberField - icon="Y" - value={positionY.displayValue} - onFocus={positionY.onFocus} - onChange={positionY.onChange} - onBlur={positionY.onBlur} - onScrub={positionY.scrubTo} - onScrubEnd={positionY.commitScrub} - onReset={() => - positionY.commitValue({ - value: DEFAULTS.element.transform.position.y, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.y, - staticValue: element.transform.position.y, - defaultValue: DEFAULTS.element.transform.position.y, - })} - /> - </SectionField> - </div> - - <SectionField - label="Rotation" - beforeLabel={ - <KeyframeToggle - isActive={rotation.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle rotation keyframe" - onToggle={rotation.toggleKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - <NumberField - icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} - className="flex-none" - value={rotation.displayValue} - onFocus={rotation.onFocus} - onChange={rotation.onChange} - onBlur={rotation.onBlur} - dragSensitivity="slow" - onScrub={rotation.scrubTo} - onScrubEnd={rotation.commitScrub} - onReset={() => - rotation.commitValue({ - value: DEFAULTS.element.transform.rotate, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.rotate, - staticValue: element.transform.rotate, - defaultValue: DEFAULTS.element.transform.rotate, - })} - /> - </div> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/rendering/index.ts b/apps/web/src/rendering/index.ts index c5555371..21e66447 100644 --- a/apps/web/src/rendering/index.ts +++ b/apps/web/src/rendering/index.ts @@ -1,3 +1,5 @@ +import type { ParamValues } from "@/params";
+
export interface Transform {
scaleX: number;
scaleY: number;
@@ -26,3 +28,71 @@ export type BlendMode = | "saturation"
| "color"
| "luminosity";
+
+export function buildTransformFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): Transform {
+ return {
+ scaleX: readNumberParam({ params, key: "transform.scaleX", fallback: 1 }),
+ scaleY: readNumberParam({ params, key: "transform.scaleY", fallback: 1 }),
+ position: {
+ x: readNumberParam({ params, key: "transform.positionX", fallback: 0 }),
+ y: readNumberParam({ params, key: "transform.positionY", fallback: 0 }),
+ },
+ rotate: readNumberParam({ params, key: "transform.rotate", fallback: 0 }),
+ };
+}
+
+export function readOpacityFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): number {
+ return readNumberParam({ params, key: "opacity", fallback: 1 });
+}
+
+export function readBlendModeFromParams({
+ params,
+}: {
+ params: ParamValues;
+}): BlendMode {
+ const value = params.blendMode;
+ return typeof value === "string" && isBlendMode(value) ? value : "normal";
+}
+
+function readNumberParam({
+ params,
+ key,
+ fallback,
+}: {
+ params: ParamValues;
+ key: string;
+ fallback: number;
+}): number {
+ const value = params[key];
+ return typeof value === "number" ? value : fallback;
+}
+
+function isBlendMode(value: string): value is BlendMode {
+ return (
+ value === "normal" ||
+ value === "darken" ||
+ value === "multiply" ||
+ value === "color-burn" ||
+ value === "lighten" ||
+ value === "screen" ||
+ value === "plus-lighter" ||
+ value === "color-dodge" ||
+ value === "overlay" ||
+ value === "soft-light" ||
+ value === "hard-light" ||
+ value === "difference" ||
+ value === "exclusion" ||
+ value === "hue" ||
+ value === "saturation" ||
+ value === "color" ||
+ value === "luminosity"
+ );
+}
diff --git a/apps/web/src/selection/hooks/use-box-select.ts b/apps/web/src/selection/hooks/use-box-select.ts index 90a584b5..3bbff14e 100644 --- a/apps/web/src/selection/hooks/use-box-select.ts +++ b/apps/web/src/selection/hooks/use-box-select.ts @@ -4,15 +4,40 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { BoxSelectionSnapshot, ResolveIntersections, + SelectionBoxBounds, } from "@/selection/types"; interface SelectionBoxState<TId> extends BoxSelectionSnapshot<TId> { startPos: { x: number; y: number }; currentPos: { x: number; y: number }; + bounds: SelectionBoxBounds | null; isActive: boolean; isAdditive: boolean; } +function getSelectionBoxBounds({ + container, + startPos, + currentPos, +}: { + container: HTMLElement; + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; +}): SelectionBoxBounds { + const containerRect = container.getBoundingClientRect(); + const startX = startPos.x - containerRect.left; + const startY = startPos.y - containerRect.top; + const currentX = currentPos.x - containerRect.left; + const currentY = currentPos.y - containerRect.top; + + return { + left: Math.min(startX, currentX), + top: Math.min(startY, currentY), + width: Math.abs(currentX - startX), + height: Math.abs(currentY - startY), + }; +} + export function useBoxSelect<TId>({ containerRef, resolveIntersections, @@ -40,33 +65,43 @@ export function useBoxSelect<TId>({ const [selectionBox, setSelectionBox] = useState<SelectionBoxState<TId> | null>(null); const justFinishedSelectingRef = useRef(false); - const shouldStartSelectionCheck = shouldStartSelection ?? (() => true); - const getIsAdditiveSelectionCheck = - getIsAdditiveSelection ?? - ((event: React.MouseEvent<Element>) => event.ctrlKey || event.metaKey); const handleMouseDown = useCallback( (event: React.MouseEvent<Element>) => { - const canStartSelection = shouldStartSelectionCheck(event); + const canStartSelection = shouldStartSelection + ? shouldStartSelection(event) + : true; if (!isEnabled || event.button !== 0 || !canStartSelection) { return; } + const startPos = { x: event.clientX, y: event.clientY }; + const container = containerRef.current; setSelectionBox({ - startPos: { x: event.clientX, y: event.clientY }, - currentPos: { x: event.clientX, y: event.clientY }, + startPos, + currentPos: startPos, + bounds: container + ? getSelectionBoxBounds({ + container, + startPos, + currentPos: startPos, + }) + : null, isActive: false, - isAdditive: getIsAdditiveSelectionCheck(event), + isAdditive: getIsAdditiveSelection + ? getIsAdditiveSelection(event) + : event.ctrlKey || event.metaKey, initialSelectedIds: selectedIds, initialAnchorId: anchorId, }); }, [ anchorId, - getIsAdditiveSelectionCheck, + containerRef, + getIsAdditiveSelection, isEnabled, selectedIds, - shouldStartSelectionCheck, + shouldStartSelection, ], ); @@ -98,11 +133,20 @@ export function useBoxSelect<TId>({ } const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { + const currentPos = { x: clientX, y: clientY }; const deltaX = Math.abs(clientX - selectionBox.startPos.x); const deltaY = Math.abs(clientY - selectionBox.startPos.y); + const container = containerRef.current; const nextSelectionBox = { ...selectionBox, - currentPos: { x: clientX, y: clientY }, + currentPos, + bounds: container + ? getSelectionBoxBounds({ + container, + startPos: selectionBox.startPos, + currentPos, + }) + : null, isActive: deltaX > 5 || deltaY > 5 || selectionBox.isActive, }; @@ -133,26 +177,26 @@ export function useBoxSelect<TId>({ window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; - }, [selectionBox, updateSelection]); + }, [containerRef, selectionBox, updateSelection]); useEffect(() => { if (!selectionBox) { return; } + const container = containerRef.current; const previousBodyUserSelect = document.body.style.userSelect; - const previousContainerUserSelect = - containerRef.current?.style.userSelect ?? ""; + const previousContainerUserSelect = container?.style.userSelect ?? ""; document.body.style.userSelect = "none"; - if (containerRef.current) { - containerRef.current.style.userSelect = "none"; + if (container) { + container.style.userSelect = "none"; } return () => { document.body.style.userSelect = previousBodyUserSelect; - if (containerRef.current) { - containerRef.current.style.userSelect = previousContainerUserSelect; + if (container) { + container.style.userSelect = previousContainerUserSelect; } }; }, [containerRef, selectionBox]); @@ -162,7 +206,10 @@ export function useBoxSelect<TId>({ }, []); return { - selectionBox, + selectionBox: + selectionBox?.isActive && selectionBox.bounds + ? { bounds: selectionBox.bounds } + : null, handleMouseDown, isSelecting: selectionBox?.isActive ?? false, shouldIgnoreClick, diff --git a/apps/web/src/selection/hooks/use-selection-scope.ts b/apps/web/src/selection/hooks/use-selection-scope.ts index ad4d2ce8..7fddab08 100644 --- a/apps/web/src/selection/hooks/use-selection-scope.ts +++ b/apps/web/src/selection/hooks/use-selection-scope.ts @@ -1,28 +1,25 @@ -import { useEffect, useRef } from "react";
+import { useEffect, useState } from "react";
+import { useCommittedRef } from "@/hooks/use-committed-ref";
import { useSelectionContext } from "@/selection/context";
import { activateScope, type ScopeEntry } from "@/selection/scope";
export function useSelectionScope() {
const { selectedIds, clearSelection } = useSelectionContext();
- const hasSelectionRef = useRef(selectedIds.length > 0);
- const clearSelectionRef = useRef(clearSelection);
const hasSelection = selectedIds.length > 0;
-
- hasSelectionRef.current = hasSelection;
- clearSelectionRef.current = clearSelection;
-
- const entryRef = useRef<ScopeEntry>({
+ const hasSelectionRef = useCommittedRef(hasSelection);
+ const clearSelectionRef = useCommittedRef(clearSelection);
+ const [entry] = useState<ScopeEntry>(() => ({
hasSelection: () => hasSelectionRef.current,
clear: () => {
clearSelectionRef.current();
},
- });
+ }));
useEffect(() => {
if (!hasSelection) {
return;
}
- return activateScope({ entry: entryRef.current });
- }, [hasSelection]);
+ return activateScope({ entry });
+ }, [entry, hasSelection]);
}
diff --git a/apps/web/src/selection/selectable-item.tsx b/apps/web/src/selection/selectable-item.tsx index cc9e1dbf..9735c4a8 100644 --- a/apps/web/src/selection/selectable-item.tsx +++ b/apps/web/src/selection/selectable-item.tsx @@ -6,7 +6,13 @@ import { SELECTABLE_ITEM_ATTRIBUTE } from "@/selection/attributes"; import type { SelectableItemProps } from "@/selection/types";
import { cn } from "@/utils/ui";
-function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
+function setForwardedRef<T>({
+ ref,
+ value,
+}: {
+ ref: React.ForwardedRef<T>;
+ value: T | null;
+}) {
if (typeof ref === "function") {
ref(value);
return;
@@ -55,7 +61,7 @@ export const SelectableItem = forwardRef<HTMLDivElement, SelectableItemProps>( const handleRef = useCallback(
(element: HTMLDivElement | null) => {
registerItem(id, element);
- setForwardedRef(forwardedRef, element);
+ setForwardedRef({ ref: forwardedRef, value: element });
},
[forwardedRef, id, registerItem],
);
diff --git a/apps/web/src/selection/selectable-surface.tsx b/apps/web/src/selection/selectable-surface.tsx index 24166daf..12682ba8 100644 --- a/apps/web/src/selection/selectable-surface.tsx +++ b/apps/web/src/selection/selectable-surface.tsx @@ -167,14 +167,15 @@ export function SelectableSurface({ [],
);
- const { selectionBox, handleMouseDown, shouldIgnoreClick } = useBoxSelect({
- containerRef,
- resolveIntersections,
- selectedIds: selectionState.selectedIds,
- anchorId: selectionState.anchorId,
- onSelectionChange: handleBoxSelectionChange,
- shouldStartSelection,
- });
+ const { selectionBox, handleMouseDown, isSelecting, shouldIgnoreClick } =
+ useBoxSelect({
+ containerRef,
+ resolveIntersections,
+ selectedIds: selectionState.selectedIds,
+ anchorId: selectionState.anchorId,
+ onSelectionChange: handleBoxSelectionChange,
+ shouldStartSelection,
+ });
const handleBackgroundClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
@@ -236,7 +237,7 @@ export function SelectableSurface({ return () => clearTimeout(timer);
}, [getItemElement, onRevealComplete, revealId]);
- const isBoxSelecting = selectionBox?.isActive ?? false;
+ const isBoxSelecting = isSelecting;
const contextValue = useMemo(() => {
return {
@@ -276,12 +277,7 @@ export function SelectableSurface({ onKeyDown={handleBackgroundKeyDown}
>
{children}
- <SelectionBox
- startPos={selectionBox?.startPos ?? null}
- currentPos={selectionBox?.currentPos ?? null}
- containerRef={containerRef}
- isActive={selectionBox?.isActive ?? false}
- />
+ <SelectionBox bounds={selectionBox?.bounds ?? null} />
</div>
</SelectionContext.Provider>
);
diff --git a/apps/web/src/selection/selection-box.tsx b/apps/web/src/selection/selection-box.tsx index e30f574d..2f2bf933 100644 --- a/apps/web/src/selection/selection-box.tsx +++ b/apps/web/src/selection/selection-box.tsx @@ -1,49 +1,22 @@ "use client";
-import { useMemo } from "react";
+import type { SelectionBoxBounds } from "@/selection/types";
interface SelectionBoxProps {
- startPos: { x: number; y: number } | null;
- currentPos: { x: number; y: number } | null;
- containerRef: React.RefObject<HTMLElement | null>;
- isActive: boolean;
+ bounds: SelectionBoxBounds | null;
}
-export function SelectionBox({
- startPos,
- currentPos,
- containerRef,
- isActive,
-}: SelectionBoxProps) {
- const selectionBoxStyle = useMemo(() => {
- if (!isActive || !startPos || !currentPos || !containerRef.current) {
- return null;
- }
-
- const containerRect = containerRef.current.getBoundingClientRect();
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const currentX = currentPos.x - containerRect.left;
- const currentY = currentPos.y - containerRect.top;
-
- const left = Math.min(startX, currentX);
- const top = Math.min(startY, currentY);
- const width = Math.abs(currentX - startX);
- const height = Math.abs(currentY - startY);
-
- return {
- left: `${left}px`,
- top: `${top}px`,
- width: `${width}px`,
- height: `${height}px`,
- };
- }, [containerRef, currentPos, isActive, startPos]);
-
- if (!selectionBoxStyle) return null;
+export function SelectionBox({ bounds }: SelectionBoxProps) {
+ if (!bounds) return null;
return (
<div
- style={selectionBoxStyle}
+ style={{
+ left: `${bounds.left}px`,
+ top: `${bounds.top}px`,
+ width: `${bounds.width}px`,
+ height: `${bounds.height}px`,
+ }}
className="border-foreground/50 bg-foreground/5 pointer-events-none absolute z-50 border"
/>
);
diff --git a/apps/web/src/selection/types.ts b/apps/web/src/selection/types.ts index 31520788..a797d6c0 100644 --- a/apps/web/src/selection/types.ts +++ b/apps/web/src/selection/types.ts @@ -8,6 +8,13 @@ export interface BoxSelectionSnapshot<TId = string> { initialAnchorId: TId | null;
}
+export interface SelectionBoxBounds {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
export interface BoxSelectionChange<TId = string>
extends BoxSelectionSnapshot<TId> {
intersectedIds: TId[];
diff --git a/apps/web/src/services/renderer/canvas-renderer.ts b/apps/web/src/services/renderer/canvas-renderer.ts index 4a002675..58294e9c 100644 --- a/apps/web/src/services/renderer/canvas-renderer.ts +++ b/apps/web/src/services/renderer/canvas-renderer.ts @@ -1,5 +1,6 @@ import type { FrameRate } from "opencut-wasm"; import type { AnyBaseNode } from "./nodes/base-node"; +import { createCanvasSurface } from "./canvas-utils"; import { buildFrameDescriptor } from "./compositor/frame-descriptor"; import { wasmCompositor } from "./compositor/wasm-compositor"; import { resolveRenderTree } from "./resolve"; @@ -16,8 +17,8 @@ export type CanvasRendererParams = { }; export class CanvasRenderer { - canvas: OffscreenCanvas | HTMLCanvasElement; - context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; width: number; height: number; fps: FrameRate; @@ -27,22 +28,9 @@ export class CanvasRenderer { this.height = height; this.fps = fps; - try { - this.canvas = new OffscreenCanvas(width, height); - } catch { - this.canvas = document.createElement("canvas"); - this.canvas.width = width; - this.canvas.height = height; - } - - const context = this.canvas.getContext("2d"); - if (!context) { - throw new Error("Failed to get canvas context"); - } - - this.context = context as - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; + const surface = createCanvasSurface({ width, height }); + this.canvas = surface.canvas; + this.context = surface.context; } getOutputCanvas(): HTMLCanvasElement { @@ -57,20 +45,9 @@ export class CanvasRenderer { this.width = width; this.height = height; - if (this.canvas instanceof OffscreenCanvas) { - this.canvas = new OffscreenCanvas(width, height); - } else { - this.canvas.width = width; - this.canvas.height = height; - } - - const context = this.canvas.getContext("2d"); - if (!context) { - throw new Error("Failed to get canvas context"); - } - this.context = context as - | OffscreenCanvasRenderingContext2D - | CanvasRenderingContext2D; + const surface = createCanvasSurface({ width, height }); + this.canvas = surface.canvas; + this.context = surface.context; } async render({ node, time }: { node: AnyBaseNode; time: number }) { diff --git a/apps/web/src/services/renderer/canvas-utils.ts b/apps/web/src/services/renderer/canvas-utils.ts index 093168e6..6981e6d5 100644 --- a/apps/web/src/services/renderer/canvas-utils.ts +++ b/apps/web/src/services/renderer/canvas-utils.ts @@ -1,16 +1,17 @@ -export function createOffscreenCanvas({ +export function createCanvasSurface({ width, height, }: { width: number; height: number; -}): OffscreenCanvas | HTMLCanvasElement { - try { - return new OffscreenCanvas(width, height); - } catch { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - return canvas; +}): { + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; +} { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to create 2D rendering context"); } + return { canvas, context }; } diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts index 190a0237..d5ab88c3 100644 --- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts +++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts @@ -1,9 +1,9 @@ import { drawCssBackground } from "@/gradients"; -import { masksRegistry } from "@/masks"; +import { getMaskDefinition } from "@/masks"; import { incrementCounter } from "@/diagnostics/render-perf"; import type { AnyBaseNode } from "../nodes/base-node"; import type { CanvasRenderer } from "../canvas-renderer"; -import { createOffscreenCanvas } from "../canvas-utils"; +import { createCanvasSurface } from "../canvas-utils"; import { BlurBackgroundNode } from "../nodes/blur-background-node"; import { ColorNode } from "../nodes/color-node"; import { EffectLayerNode } from "../nodes/effect-layer-node"; @@ -390,58 +390,71 @@ function buildMaskArtifacts({ return { mask: null, strokeLayer: null }; } - const definition = masksRegistry.get(mask.type); + const definition = getMaskDefinition(mask.type); if (definition.isActive?.(mask.params) === false) { return { mask: null, strokeLayer: null }; } - let feather = mask.params.feather; - const canRenderMaskDirectly = Boolean(definition.renderer.renderMask); - const shouldRenderMaskDirectly = - canRenderMaskDirectly && - (!definition.renderer.buildPath || - (mask.params.feather > 0 && - definition.renderer.renderMaskHandlesFeather)); - if ( - shouldRenderMaskDirectly && - definition.renderer.renderMaskHandlesFeather - ) { - feather = 0; - } + const { body } = definition.renderer; + const usesOpaqueFastPath = + body.kind === "drawWithFeather" && + mask.params.feather === 0 && + Boolean(body.opaqueFastPath); + // drawWithFeather renderers encode feathering analytically in their canvas output + // (e.g. split mask uses a linear gradient instead of JFA). The descriptor feather is + // zeroed so the GPU compositor copies the mask texture as-is and does not run a second + // JFA feather pass on top of an already-soft texture. + const feather = body.kind === "drawWithFeather" ? 0 : mask.params.feather; const maskTextureId = `${path}:mask`; const { width: canvasWidth, height: canvasHeight } = renderer; - const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`; + const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:body=${body.kind}:fastPath=${usesOpaqueFastPath}`; const drawMask: TextureCanvasDrawFn = (ctx) => { - const elementMaskCanvas = createOffscreenCanvas({ - width: Math.round(transform.width), - height: Math.round(transform.height), - }); - const elementMaskCtx = elementMaskCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!elementMaskCtx) return; - - if (shouldRenderMaskDirectly && definition.renderer.renderMask) { - definition.renderer.renderMask({ - resolvedParams: mask.params, - ctx: elementMaskCtx, + const { canvas: elementMaskCanvas, context: elementMaskCtx } = + createCanvasSurface({ width: Math.round(transform.width), height: Math.round(transform.height), - feather: mask.params.feather, - }); - } else if (definition.renderer.buildPath) { - const path2d = definition.renderer.buildPath({ - resolvedParams: mask.params, - width: transform.width, - height: transform.height, }); - elementMaskCtx.fillStyle = "white"; - elementMaskCtx.fill(path2d); - } else { - return; + + switch (body.kind) { + case "fillPath": { + const path2d = body.buildPath({ + resolvedParams: mask.params, + width: transform.width, + height: transform.height, + }); + elementMaskCtx.fillStyle = "white"; + elementMaskCtx.fill(path2d); + break; + } + case "drawOpaque": + body.drawOpaque({ + resolvedParams: mask.params, + ctx: elementMaskCtx, + width: Math.round(transform.width), + height: Math.round(transform.height), + }); + break; + case "drawWithFeather": + if (usesOpaqueFastPath && body.opaqueFastPath) { + const path2d = body.opaqueFastPath.buildPath({ + resolvedParams: mask.params, + width: transform.width, + height: transform.height, + }); + elementMaskCtx.fillStyle = "white"; + elementMaskCtx.fill(path2d); + } else { + body.drawWithFeather({ + resolvedParams: mask.params, + ctx: elementMaskCtx, + width: Math.round(transform.width), + height: Math.round(transform.height), + feather: mask.params.feather, + }); + } + break; } drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform }); @@ -455,50 +468,38 @@ function buildMaskArtifacts({ draw: drawMask, }); - const hasStroke = - mask.params.strokeWidth > 0 && - (definition.renderer.renderStroke || - definition.renderer.buildStrokePath || - definition.renderer.buildPath); + const stroke = definition.renderer.stroke; + const hasStroke = mask.params.strokeWidth > 0 && Boolean(stroke); let strokeLayer: FrameItemDescriptor | null = null; - if (hasStroke) { + if (hasStroke && stroke) { const strokeTextureId = `${path}:mask-stroke`; - const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`; + const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:stroke=${stroke.kind}`; const drawStroke: TextureCanvasDrawFn = (ctx) => { - const strokeCanvas = createOffscreenCanvas({ + const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({ width: Math.round(transform.width), height: Math.round(transform.height), }); - const strokeCtx = strokeCanvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!strokeCtx) return; - if (definition.renderer.renderStroke) { - definition.renderer.renderStroke({ - resolvedParams: mask.params, - ctx: strokeCtx, - width: transform.width, - height: transform.height, - }); - } else { - const strokePath = - definition.renderer.buildStrokePath?.({ + switch (stroke.kind) { + case "renderStroke": + stroke.renderStroke({ resolvedParams: mask.params, + ctx: strokeCtx, width: transform.width, height: transform.height, - }) ?? - definition.renderer.buildPath?.({ + }); + break; + case "strokeFromPath": { + const strokePath = stroke.buildStrokePath({ resolvedParams: mask.params, width: transform.width, height: transform.height, - }) ?? - null; - if (!strokePath) return; - strokeCtx.strokeStyle = mask.params.strokeColor; - strokeCtx.lineWidth = mask.params.strokeWidth; - strokeCtx.stroke(strokePath); + }); + strokeCtx.strokeStyle = mask.params.strokeColor; + strokeCtx.lineWidth = mask.params.strokeWidth; + strokeCtx.stroke(strokePath); + break; + } } drawTransformedCanvas({ ctx, source: strokeCanvas, transform }); diff --git a/apps/web/src/services/renderer/effect-preview.ts b/apps/web/src/services/renderer/effect-preview.ts index c4cacde0..cbfb1a31 100644 --- a/apps/web/src/services/renderer/effect-preview.ts +++ b/apps/web/src/services/renderer/effect-preview.ts @@ -1,4 +1,4 @@ -import { createOffscreenCanvas } from "./canvas-utils"; +import { createCanvasSurface } from "./canvas-utils"; import { effectsRegistry, resolveEffectPasses } from "@/effects"; import { buildDefaultParamValues } from "@/params/registry"; import type { ParamValues } from "@/params"; @@ -8,7 +8,7 @@ const PREVIEW_SIZE = 160; const PREVIEW_IMAGE_PATH = "/effects/preview.jpg"; class EffectPreviewService { - private testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null; + private testSourceCanvas: OffscreenCanvas | null = null; private previewImageElement: HTMLImageElement | null = null; private onReadyCallbacks = new Set<() => void>(); @@ -98,7 +98,7 @@ class EffectPreviewService { }: { width: number; height: number; - }): OffscreenCanvas | HTMLCanvasElement | null { + }): OffscreenCanvas | null { const isImageReady = this.previewImageElement?.complete && (this.previewImageElement.naturalWidth ?? 0) > 0; @@ -106,15 +106,8 @@ class EffectPreviewService { return null; } - const canvas = createOffscreenCanvas({ width, height }); - const ctx = canvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!ctx) { - throw new Error("failed to get 2d context for test source"); - } - ctx.drawImage(this.previewImageElement, 0, 0, width, height); + const { canvas, context } = createCanvasSurface({ width, height }); + context.drawImage(this.previewImageElement, 0, 0, width, height); return canvas; } @@ -124,7 +117,7 @@ class EffectPreviewService { }: { width: number; height: number; - }): CanvasImageSource | null { + }): OffscreenCanvas | null { if ( !this.testSourceCanvas || this.testSourceCanvas.width !== width || @@ -141,17 +134,17 @@ class EffectPreviewService { height, passes, }: { - source: CanvasImageSource; + source: OffscreenCanvas; width: number; height: number; passes: ReturnType<typeof resolveEffectPasses>; - }): OffscreenCanvas | HTMLCanvasElement { + }): OffscreenCanvas { return gpuRenderer.applyEffect({ source, width, height, passes, - }) as OffscreenCanvas | HTMLCanvasElement; + }); } } diff --git a/apps/web/src/services/renderer/gpu-renderer.ts b/apps/web/src/services/renderer/gpu-renderer.ts index c61b6b98..c1be3f1a 100644 --- a/apps/web/src/services/renderer/gpu-renderer.ts +++ b/apps/web/src/services/renderer/gpu-renderer.ts @@ -34,23 +34,17 @@ export const gpuRenderer = { height, passes, }: { - source: CanvasImageSource; + source: OffscreenCanvas; width: number; height: number; passes: EffectPass[]; - }): CanvasImageSource { + }): OffscreenCanvas { if (passes.length === 0 || !gpuAvailable) { return source; } - const sourceCanvas = ensureOffscreenCanvas({ - source, - width, - height, - label: "effect source", - }); return applyEffectPasses({ - source: sourceCanvas, + source, width, height, passes: serializeEffectPasses(passes), @@ -63,23 +57,17 @@ export const gpuRenderer = { height, feather, }: { - maskCanvas: CanvasImageSource; + maskCanvas: OffscreenCanvas; width: number; height: number; feather: number; - }): CanvasImageSource { + }): OffscreenCanvas { if (!gpuAvailable) { return maskCanvas; } - const sourceCanvas = ensureOffscreenCanvas({ - source: maskCanvas, - width, - height, - label: "mask source", - }); return applyMaskFeatherWasm({ - mask: sourceCanvas, + mask: maskCanvas, width, height, feather, @@ -87,35 +75,6 @@ export const gpuRenderer = { }, }; -function ensureOffscreenCanvas({ - source, - width, - height, - label, -}: { - source: CanvasImageSource; - width: number; - height: number; - label: string; -}): OffscreenCanvas { - if (source instanceof OffscreenCanvas) { - return source; - } - - if (typeof OffscreenCanvas === "undefined") { - throw new Error(`OffscreenCanvas is required for the GPU ${label}`); - } - - const canvas = new OffscreenCanvas(width, height); - const context = canvas.getContext("2d"); - if (!context) { - throw new Error(`Failed to get 2d context for the GPU ${label}`); - } - context.clearRect(0, 0, width, height); - context.drawImage(source, 0, 0, width, height); - return canvas; -} - function serializeEffectPasses(passes: EffectPass[]) { return passes.map((pass) => ({ shader: pass.shader, diff --git a/apps/web/src/services/renderer/mask-feather.ts b/apps/web/src/services/renderer/mask-feather.ts index 4ea7eb2b..dde4decc 100644 --- a/apps/web/src/services/renderer/mask-feather.ts +++ b/apps/web/src/services/renderer/mask-feather.ts @@ -6,15 +6,15 @@ export function applyMaskFeather({ height, feather, }: { - maskCanvas: CanvasImageSource; + maskCanvas: OffscreenCanvas; width: number; height: number; feather: number; -}): OffscreenCanvas | HTMLCanvasElement { +}): OffscreenCanvas { return gpuRenderer.applyMaskFeather({ maskCanvas, width, height, feather, - }) as OffscreenCanvas | HTMLCanvasElement; + }); } diff --git a/apps/web/src/services/renderer/nodes/graphic-node.ts b/apps/web/src/services/renderer/nodes/graphic-node.ts index 2d022510..798b56a0 100644 --- a/apps/web/src/services/renderer/nodes/graphic-node.ts +++ b/apps/web/src/services/renderer/nodes/graphic-node.ts @@ -1,4 +1,4 @@ -import { createOffscreenCanvas } from "../canvas-utils"; +import { createCanvasSurface } from "../canvas-utils"; import { DEFAULT_GRAPHIC_SOURCE_SIZE, getGraphicDefinition, @@ -25,7 +25,7 @@ export class GraphicNode extends VisualNode< ResolvedGraphicNodeState > { private cachedKey: string | null = null; - private cachedSource: OffscreenCanvas | HTMLCanvasElement | null = null; + private cachedSource: OffscreenCanvas | null = null; constructor(params: GraphicNodeParams) { super(params); @@ -36,7 +36,7 @@ export class GraphicNode extends VisualNode< resolvedParams, }: { resolvedParams: ParamValues; - }): OffscreenCanvas | HTMLCanvasElement | null { + }): OffscreenCanvas { const definition = getGraphicDefinition({ definitionId: this.params.definitionId, }); @@ -48,20 +48,13 @@ export class GraphicNode extends VisualNode< return this.cachedSource; } - const canvas = createOffscreenCanvas({ + const { canvas, context } = createCanvasSurface({ width: DEFAULT_GRAPHIC_SOURCE_SIZE, height: DEFAULT_GRAPHIC_SOURCE_SIZE, }); - const ctx = canvas.getContext("2d") as - | CanvasRenderingContext2D - | OffscreenCanvasRenderingContext2D - | null; - if (!ctx) { - return null; - } definition.render({ - ctx, + ctx: context, params: resolvedParams, width: DEFAULT_GRAPHIC_SOURCE_SIZE, height: DEFAULT_GRAPHIC_SOURCE_SIZE, diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index d8c56803..4f475a2d 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -17,10 +17,13 @@ export interface CachedImageSource { const imageSourceCache = new Map<string, Promise<CachedImageSource>>(); -export function loadImageSource( - url: string, - maxSourceSize?: number, -): Promise<CachedImageSource> { +export function loadImageSource({ + url, + maxSourceSize, +}: { + url: string; + maxSourceSize?: number; +}): Promise<CachedImageSource> { const cacheKey = `${url}::${maxSourceSize ?? "full"}`; const cached = imageSourceCache.get(cacheKey); diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 7bdf135c..6635a031 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -1,11 +1,14 @@ import { BaseNode } from "./base-node"; import type { TextElement } from "@/timeline"; import type { EffectPass } from "@/effects/types"; -import type { Transform } from "@/rendering"; +import type { BlendMode, Transform } from "@/rendering"; import { drawMeasuredTextLayout } from "@/text/primitives"; import type { MeasuredTextElement } from "@/text/measure-element"; export type TextNodeParams = TextElement & { + transform: Transform; + opacity: number; + blendMode?: BlendMode; canvasCenter: { x: number; y: number }; canvasHeight: number; textBaseline?: CanvasTextBaseline; diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 809f7103..5335de2d 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -1,4 +1,4 @@ -import { mediaTimeToSeconds } from "opencut-wasm"; +import { mediaTimeToSeconds, roundMediaTime } from "@/wasm"; import { getElementLocalTime } from "@/animation"; import { resolveEffectParamsAtTime } from "@/animation/effect-param-channel"; import { @@ -13,6 +13,7 @@ import { resolveGraphicElementParamsAtTime, } from "@/graphics"; import { + buildTextBackgroundFromElement, getTextMeasurementContext, measureTextElement, } from "@/text/measure-element"; @@ -204,7 +205,7 @@ async function resolveVideoNode({ const frame = await videoCache.getFrameAt({ mediaId: node.params.mediaId, file: node.params.file, - time: mediaTimeToSeconds({ time: sourceTimeTicks }), + time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }), }); if (!frame) { return null; @@ -235,10 +236,10 @@ async function resolveImageNode({ node: ImageNode; context: ResolveContext; }): Promise<ResolvedVisualSourceNodeState | null> { - const source = await loadImageSource( - node.params.url, - node.params.maxSourceSize, - ); + const source = await loadImageSource({ + url: node.params.url, + maxSourceSize: node.params.maxSourceSize, + }); const visualState = resolveVisualState({ params: node.params, context, @@ -330,6 +331,7 @@ function resolveTextNode({ elementStartTime: node.params.startTime, elementDuration: node.params.duration, }); + const background = buildTextBackgroundFromElement({ element: node.params }); return { transform: resolveTransformAtTime({ @@ -343,13 +345,16 @@ function resolveTextNode({ localTime, }), textColor: resolveColorAtTime({ - baseColor: node.params.color, + baseColor: + typeof node.params.params.color === "string" + ? node.params.params.color + : "#ffffff", animations: node.params.animations, propertyPath: "color", localTime, }), backgroundColor: resolveColorAtTime({ - baseColor: node.params.background.color, + baseColor: background.color, animations: node.params.animations, propertyPath: "background.color", localTime, @@ -421,7 +426,7 @@ async function resolveBackdropSource({ const frame = await videoCache.getFrameAt({ mediaId: node.params.mediaId, file: node.params.file, - time: mediaTimeToSeconds({ time: sourceTimeTicks }), + time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }), }); if (!frame) { return null; @@ -434,7 +439,7 @@ async function resolveBackdropSource({ }; } - const source = await loadImageSource(node.params.url); + const source = await loadImageSource({ url: node.params.url }); return { source: source.source, width: source.width, diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index e79c62d4..431b8269 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -12,6 +12,11 @@ import { EffectLayerNode } from "./nodes/effect-layer-node"; import type { AnyBaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/project/types"; import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/background/blur"; +import { + buildTransformFromParams, + readBlendModeFromParams, + readOpacityFromParams, +} from "@/rendering"; const PREVIEW_MAX_IMAGE_SIZE = 2048; @@ -71,10 +76,10 @@ function buildTrackNodes({ trimStart: element.trimStart, trimEnd: element.trimEnd, retime: element.retime, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], }), @@ -88,10 +93,10 @@ function buildTrackNodes({ timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], ...(isPreview && { @@ -106,6 +111,9 @@ function buildTrackNodes({ nodes.push( new TextNode({ ...element, + transform: buildTransformFromParams({ params: element.params }), + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, canvasHeight: canvasSize.height, textBaseline: "middle", @@ -124,10 +132,10 @@ function buildTrackNodes({ timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], }), ); @@ -142,10 +150,10 @@ function buildTrackNodes({ timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, - transform: element.transform, + transform: buildTransformFromParams({ params: element.params }), animations: element.animations, - opacity: element.opacity, - blendMode: element.blendMode, + opacity: readOpacityFromParams({ params: element.params }), + blendMode: readBlendModeFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], }), diff --git a/apps/web/src/services/storage/indexeddb-adapter.ts b/apps/web/src/services/storage/indexeddb-adapter.ts index 2df4feaa..4a7498b6 100644 --- a/apps/web/src/services/storage/indexeddb-adapter.ts +++ b/apps/web/src/services/storage/indexeddb-adapter.ts @@ -5,7 +5,15 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> { private storeName: string; private version: number; - constructor(dbName: string, storeName: string, version = 1) { + constructor({ + dbName, + storeName, + version = 1, + }: { + dbName: string; + storeName: string; + version?: number; + }) { this.dbName = dbName; this.storeName = storeName; this.version = version; @@ -39,7 +47,13 @@ export class IndexedDBAdapter<T> implements StorageAdapter<T> { }); } - async set(key: string, value: T): Promise<void> { + async set({ + key, + value, + }: { + key: string; + value: T; + }): Promise<void> { const db = await this.getDB(); const transaction = db.transaction([this.storeName], "readwrite"); const store = transaction.objectStore(this.storeName); diff --git a/apps/web/src/services/storage/migrations/AGENTS.md b/apps/web/src/services/storage/migrations/AGENTS.md new file mode 100644 index 00000000..5aed00a1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/AGENTS.md @@ -0,0 +1,10 @@ +# Agents.md + +## Migration Data Policy + +Migrations are additive only. + +- Do not delete, rename, or replace persisted data in a migration. +- When a new storage shape is needed, add the new fields alongside the old fields. +- Runtime code may prefer or ignore old fields, but the migration must preserve them for future down migrations. +- If old data should ever be removed, that is a separate explicit cleanup task, not part of a version migration.
\ No newline at end of file diff --git a/apps/web/src/services/storage/migrations/__tests__/helpers.ts b/apps/web/src/services/storage/migrations/__tests__/helpers.ts new file mode 100644 index 00000000..381cb935 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/helpers.ts @@ -0,0 +1,39 @@ +/** + * Type-safe accessors for navigating migration output. + * + * Migrations input/output `Record<string, unknown>` because they handle + * potentially malformed data from older versions. Tests need to inspect + * specific properties of the migrated result without using type assertions. + * + * These helpers narrow through type guards (Array.isArray + a record + * predicate), so the file contains no unsafe assertions despite turning + * unknown into concrete shapes. + */ + +function describe(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function asRecord(value: unknown): Record<string, unknown> { + if (!isRecord(value)) { + throw new Error(`Expected record, got ${describe(value)}`); + } + return value; +} + +export function asArray(value: unknown): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`Expected array, got ${describe(value)}`); + } + return value; +} + +export function asRecordArray(value: unknown): Record<string, unknown>[] { + return asArray(value).map(asRecord); +} diff --git a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts index 2cd67da8..73b64fed 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v0-to-v1.test.ts @@ -8,6 +8,7 @@ import { v0ProjectWithMetadata, v1Project, } from "./fixtures"; +import { asArray, asRecord, asRecordArray } from "./helpers"; describe("V0 to V1 Migration", () => { const fixedDate = new Date("2024-06-01T12:00:00.000Z"); @@ -22,7 +23,7 @@ describe("V0 to V1 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(1); expect(Array.isArray(result.project.scenes)).toBe(true); - expect((result.project.scenes as unknown[]).length).toBe(1); + expect(asArray(result.project.scenes).length).toBe(1); expect(result.project.currentSceneId).toBeDefined(); }); @@ -32,7 +33,7 @@ describe("V0 to V1 Migration", () => { options: { now: fixedDate }, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; expect(mainScene.isMain).toBe(true); @@ -48,7 +49,7 @@ describe("V0 to V1 Migration", () => { options: { now: fixedDate }, }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.updatedAt).toBe(fixedDate.toISOString()); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts index 0840ac76..6670c479 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts @@ -11,6 +11,7 @@ import { v1ProjectWithMultipleScenes, v2Project, } from "./fixtures"; +import { asRecord, asRecordArray } from "./helpers"; const DEFAULT_FPS = 30; const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; @@ -25,7 +26,7 @@ describe("V1 to V2 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(2); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.id).toBe(v1Project.id); expect(metadata.name).toBe(v1Project.name); expect(typeof metadata.createdAt).toBe("string"); @@ -35,7 +36,7 @@ describe("V1 to V2 Migration", () => { test("creates settings object from flat properties", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(v1Project.fps); expect(settings.canvasSize).toEqual(v1Project.canvasSize); expect(settings.originalCanvasSize).toBe(null); @@ -44,8 +45,8 @@ describe("V1 to V2 Migration", () => { test("converts color background correctly", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.type).toBe("color"); expect(background.color).toBe(v1Project.backgroundColor); }); @@ -58,8 +59,8 @@ describe("V1 to V2 Migration", () => { }; const result = transformProjectV1ToV2({ project: projectWithBlur }); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.type).toBe("blur"); expect(background.blurIntensity).toBe(30); }); @@ -67,7 +68,7 @@ describe("V1 to V2 Migration", () => { test("applies legacy bookmarks to main scene", () => { const result = transformProjectV1ToV2({ project: v1Project }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes.find((s) => s.isMain === true); expect(mainScene?.bookmarks).toEqual(v1Project.bookmarks); }); @@ -77,7 +78,7 @@ describe("V1 to V2 Migration", () => { project: v1ProjectWithMultipleScenes, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const introScene = scenes.find((s) => s.name === "Intro"); expect(introScene?.bookmarks).toEqual([1.0]); }); @@ -102,7 +103,7 @@ describe("V1 to V2 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(DEFAULT_FPS); expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); }); @@ -115,11 +116,11 @@ describe("V1 to V2 Migration", () => { }; const result = transformProjectV1ToV2({ project: minimalProject }); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toBe(DEFAULT_FPS); expect(settings.canvasSize).toEqual(DEFAULT_CANVAS_SIZE); - const background = settings.background as Record<string, unknown>; + const background = asRecord(settings.background); expect(background.type).toBe("color"); expect(background.color).toBe(DEFAULT_BACKGROUND_COLOR); }); @@ -135,8 +136,8 @@ describe("V1 to V2 Migration", () => { project: projectWithBlurNoIntensity, }); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe(DEFAULT_BACKGROUND_BLUR_INTENSITY); }); @@ -183,9 +184,9 @@ describe("V1 to V2 Migration", () => { project: projectWithTracks, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; - const tracks = mainScene.tracks as Array<Record<string, unknown>>; + const tracks = asRecordArray(mainScene.tracks); expect(tracks.length).toBe(1); expect(tracks[0].name).toBe("Existing Track"); }); @@ -240,9 +241,9 @@ describe("V1 to V2 Migration", () => { context, }); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const mainScene = scenes[0]; - const tracks = mainScene.tracks as Array<Record<string, unknown>>; + const tracks = asRecordArray(mainScene.tracks); expect(Array.isArray(tracks)).toBe(true); expect(tracks).toHaveLength(1); expect(tracks[0].type).toBe("video"); @@ -302,9 +303,9 @@ describe("V1 to V2 Migration", () => { }); expect(result.skipped).toBe(false); - const scenes = result.project.scenes as Array<Record<string, unknown>>; - const tracks = scenes[0].tracks as Array<Record<string, unknown>>; - const elements = tracks[0].elements as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecordArray(scenes[0].tracks); + const elements = asRecordArray(tracks[0].elements); const textElement = elements[0]; expect(textElement.opacity).toBe(0.5); expect(textElement.transform).toEqual({ diff --git a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts index 122af2cb..1af445ac 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV15ToV16 } from "../transformers/v15-to-v16";
+import { asRecordArray } from "./helpers";
describe("V15 to V16 Migration", () => {
test("renames sticker tracks to graphic tracks", () => {
@@ -61,10 +62,10 @@ describe("V15 to V16 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(16);
- expect(
- (result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0]
- .tracks[0].type,
- ).toBe("graphic");
+ const firstTrack = asRecordArray(
+ asRecordArray(result.project.scenes)[0].tracks,
+ )[0];
+ expect(firstTrack.type).toBe("graphic");
});
test("skips projects already on v16", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts index 352ca762..10a792ca 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV16ToV17 } from "../transformers/v16-to-v17";
+import { asRecord, asRecordArray } from "./helpers";
describe("V16 to V17 Migration", () => {
test("adds center stroke alignment to masks that do not have it", () => {
@@ -97,13 +98,13 @@ describe("V16 to V17 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(17);
- const migratedMasks = (
- ((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record<string, unknown> }> }> }> }>)[0]
- .tracks[0].elements[0].masks)
- );
+ const firstScene = asRecordArray(result.project.scenes)[0];
+ const firstTrack = asRecordArray(firstScene.tracks)[0];
+ const firstElement = asRecordArray(firstTrack.elements)[0];
+ const migratedMasks = asRecordArray(firstElement.masks);
- expect(migratedMasks[0].params.strokeAlign).toBe("center");
- expect(migratedMasks[1].params.strokeAlign).toBe("outside");
+ expect(asRecord(migratedMasks[0].params).strokeAlign).toBe("center");
+ expect(asRecord(migratedMasks[1].params).strokeAlign).toBe("outside");
});
test("skips projects already on v17", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts index 0f679b6d..0cb30f2b 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV18ToV19 } from "../transformers/v18-to-v19";
+import { asRecord } from "./helpers";
describe("V18 to V19 Migration", () => {
test("adds canvas size mode and empty remembered custom size defaults", () => {
@@ -26,15 +27,13 @@ describe("V18 to V19 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(19);
- expect(
- (result.project.settings as Record<string, unknown>).canvasSizeMode,
- ).toBe("preset");
- expect(
- (result.project.settings as Record<string, unknown>).lastCustomCanvasSize,
- ).toBeNull();
- expect(
- (result.project.settings as Record<string, unknown>).originalCanvasSize,
- ).toEqual({ width: 1920, height: 1080 });
+ const settings = asRecord(result.project.settings);
+ expect(settings.canvasSizeMode).toBe("preset");
+ expect(settings.lastCustomCanvasSize).toBeNull();
+ expect(settings.originalCanvasSize).toEqual({
+ width: 1920,
+ height: 1080,
+ });
});
test("skips projects already on v19", () => {
diff --git a/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts b/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts index 0e0cfbbe..b1bfe86f 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v19-to-v20.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV19ToV20 } from "../transformers/v19-to-v20"; +import { asRecordArray } from "./helpers"; describe("V19 to V20 Migration", () => { test("backfills source audio enabled state on video elements", () => { @@ -82,19 +83,16 @@ describe("V19 to V20 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(20); - expect( - ((result.project.scenes as Array<Record<string, unknown>>)[0] - .tracks as Array<Record<string, unknown>>)[0].elements, - ).toEqual([ + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecordArray(scene.tracks); + expect(tracks[0].elements).toEqual([ expect.objectContaining({ id: "video-1", isSourceAudioEnabled: true, }), ]); expect( - (((result.project.scenes as Array<Record<string, unknown>>)[0] - .tracks as Array<Record<string, unknown>>)[1] - .elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled, + asRecordArray(tracks[1].elements)[0].isSourceAudioEnabled, ).toBeUndefined(); }); @@ -120,11 +118,10 @@ describe("V19 to V20 Migration", () => { }, }); - expect( - (((result.project.scenes as Array<Record<string, unknown>>)[0] - .tracks as Array<Record<string, unknown>>)[0] - .elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled, - ).toBe(false); + const scene = asRecordArray(result.project.scenes)[0]; + const track = asRecordArray(scene.tracks)[0]; + const element = asRecordArray(track.elements)[0]; + expect(element.isSourceAudioEnabled).toBe(false); }); test("skips projects already on v20", () => { diff --git a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts index ba8b3bfb..d18b4dd4 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v2-to-v3.test.ts @@ -8,6 +8,7 @@ import { v2ProjectWithBlurBackground, v3Project, } from "./fixtures"; +import { asRecord } from "./helpers"; describe("V2 to V3 Migration", () => { describe("transformProjectV2ToV3", () => { @@ -17,14 +18,14 @@ describe("V2 to V3 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(3); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(typeof metadata.duration).toBe("number"); }); test("calculates duration from scene tracks", () => { const result = transformProjectV2ToV3({ project: v2Project }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); // v2Project has a video element with duration 15.5 and a text element at startTime 2 with duration 5 // Total duration should be max(15.5, 2+5) = 15.5 expect(metadata.duration).toBe(15.5); @@ -36,7 +37,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); // v2ProjectWithBlurBackground has a video with duration 30 expect(metadata.duration).toBe(30); }); @@ -45,7 +46,7 @@ describe("V2 to V3 Migration", () => { const result = transformProjectV2ToV3({ project: v2ProjectEmptyScenes }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -55,7 +56,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -91,7 +92,7 @@ describe("V2 to V3 Migration", () => { test("preserves existing metadata fields", () => { const result = transformProjectV2ToV3({ project: v2Project }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.id).toBe(v2Project.metadata.id); expect(metadata.name).toBe(v2Project.metadata.name); expect(metadata.thumbnail).toBe(v2Project.metadata.thumbnail); @@ -122,7 +123,7 @@ describe("V2 to V3 Migration", () => { }); expect(result.skipped).toBe(false); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(0); }); @@ -156,7 +157,7 @@ describe("V2 to V3 Migration", () => { }; const result = transformProjectV2ToV3({ project: multiSceneProject }); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); // Duration is from main scene only, not sum of all scenes expect(metadata.duration).toBe(10); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts b/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts index 3d77a775..2737bfc3 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v20-to-v21.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV20ToV21 } from "../transformers/v20-to-v21"; +import { asRecord } from "./helpers"; const LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY = 50; @@ -27,8 +28,8 @@ describe("V20 to V21 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(21); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe(500); }); @@ -45,8 +46,8 @@ describe("V20 to V21 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record<string, unknown>; - const background = settings.background as Record<string, unknown>; + const settings = asRecord(result.project.settings); + const background = asRecord(settings.background); expect(background.blurIntensity).toBe( LEGACY_DEFAULT_BACKGROUND_BLUR_INTENSITY, ); @@ -65,7 +66,7 @@ describe("V20 to V21 Migration", () => { }); expect(result.skipped).toBe(false); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.background).toEqual({ type: "color", color: "#000000" }); }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts index 18b815b0..76d9c0e1 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV21ToV22 } from "../transformers/v21-to-v22"; +import { asRecord, asRecordArray } from "./helpers"; describe("V21 to V22 Migration", () => { test("migrates legacy animation channels to bindings and component channels", () => { @@ -77,12 +78,12 @@ describe("V21 to V22 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(22); - const scenes = result.project.scenes as Array<Record<string, unknown>>; - const tracks = scenes[0].tracks as Array<Record<string, unknown>>; - const elements = tracks[0].elements as Array<Record<string, unknown>>; - const animations = elements[0].animations as Record<string, unknown>; - const bindings = animations.bindings as Record<string, Record<string, unknown>>; - const channels = animations.channels as Record<string, Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecordArray(scenes[0].tracks); + const elements = asRecordArray(tracks[0].elements); + const animations = asRecord(elements[0].animations); + const bindings = asRecord(animations.bindings); + const channels = asRecord(animations.channels); expect(bindings.opacity).toEqual({ path: "opacity", diff --git a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts index 5b764b48..e229ecbc 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV22ToV23 } from "../transformers/v22-to-v23"; +import { asRecord, asRecordArray } from "./helpers"; describe("V22 to V23 Migration", () => { test("converts project time values from seconds to ticks and fps to a frame-rate object", () => { @@ -101,20 +102,17 @@ describe("V22 to V23 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(23); - const metadata = result.project.metadata as Record<string, unknown>; + const metadata = asRecord(result.project.metadata); expect(metadata.duration).toBe(1_860_000); - const settings = result.project.settings as Record<string, unknown>; + const settings = asRecord(result.project.settings); expect(settings.fps).toEqual({ numerator: 30_000, denominator: 1_001 }); - const timelineViewState = result.project.timelineViewState as Record< - string, - unknown - >; + const timelineViewState = asRecord(result.project.timelineViewState); expect(timelineViewState.playheadTime).toBe(150_000); expect(timelineViewState.scrollLeft).toBe(120); - const scenes = result.project.scenes as Array<Record<string, unknown>>; + const scenes = asRecordArray(result.project.scenes); const scene = scenes[0]; expect(scene.bookmarks).toEqual([ { @@ -126,8 +124,8 @@ describe("V22 to V23 Migration", () => { { time: 540_000 }, ]); - const tracks = scene.tracks as Array<Record<string, unknown>>; - const elements = tracks[0].elements as Array<Record<string, unknown>>; + const tracks = asRecordArray(scene.tracks); + const elements = asRecordArray(tracks[0].elements); const element = elements[0]; expect(element.startTime).toBe(150_000); expect(element.duration).toBe(660_000); @@ -135,8 +133,8 @@ describe("V22 to V23 Migration", () => { expect(element.trimEnd).toBe(60_000); expect(element.sourceDuration).toBe(750_000); - const animations = element.animations as Record<string, unknown>; - const channels = animations.channels as Record<string, Record<string, unknown>>; + const animations = asRecord(element.animations); + const channels = asRecord(animations.channels); expect(channels["opacity:value"]).toEqual({ kind: "scalar", keys: [ diff --git a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts index 730d958d..5e16e886 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV26ToV27 } from "../transformers/v26-to-v27"; +import { asRecord, asRecordArray } from "./helpers"; describe("V26 to V27 Migration", () => { test("converts custom mask paths from JSON strings to typed point arrays", () => { @@ -59,13 +60,13 @@ describe("V26 to V27 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(27); - const scenes = result.project.scenes as Array<Record<string, unknown>>; - const tracks = scenes[0].tracks as Record<string, unknown>; - const mainTrack = tracks.main as Record<string, unknown>; - const elements = mainTrack.elements as Array<Record<string, unknown>>; - const masks = elements[0].masks as Array<Record<string, unknown>>; - const customParams = masks[0].params as Record<string, unknown>; - const rectangleParams = masks[1].params as Record<string, unknown>; + const scenes = asRecordArray(result.project.scenes); + const tracks = asRecord(scenes[0].tracks); + const mainTrack = asRecord(tracks.main); + const elements = asRecordArray(mainTrack.elements); + const masks = asRecordArray(elements[0].masks); + const customParams = asRecord(masks[0].params); + const rectangleParams = asRecord(masks[1].params); expect(customParams.path).toEqual([ { diff --git a/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts new file mode 100644 index 00000000..cb09e07d --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v27-to-v28.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV27ToV28 } from "../transformers/v27-to-v28"; +import { asRecord, asRecordArray } from "./helpers"; + +describe("V27 to V28 Migration", () => { + test("rounds persisted media-time floats back to integer ticks", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v27-float-time", + version: 27, + metadata: { + id: "project-v27-float-time", + name: "Project", + duration: 2_152_466.3677130044, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + settings: { + fps: { numerator: 30, denominator: 1 }, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + timelineViewState: { + zoomLevel: 1.25, + scrollLeft: 120, + playheadTime: 301_234.8, + }, + scenes: [ + { + id: "scene-1", + name: "Scene 1", + isMain: true, + bookmarks: [ + { + time: 120_000.4, + duration: 60_000.6, + note: "Marker", + color: "#ff0000", + }, + ], + tracks: { + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [ + { + id: "element-1", + type: "video", + name: "Clip", + startTime: 300_000.49, + duration: 2_152_466.3677130044, + trimStart: 30_000.2, + trimEnd: 15_000.7, + sourceDuration: 2_197_467.6, + mediaId: "media-1", + transform: { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + animations: { + channels: { + opacity: { + kind: "scalar", + keys: [ + { + id: "key-1", + time: 1_000.6, + value: 1, + segmentToNext: "bezier", + tangentMode: "flat", + leftHandle: { dt: -120.6, dv: 0 }, + rightHandle: { dt: 60.4, dv: 0 }, + }, + ], + }, + }, + }, + }, + ], + }, + overlay: [], + audio: [], + }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(28); + + const metadata = asRecord(result.project.metadata); + expect(metadata.duration).toBe(2_152_466); + + const timelineViewState = asRecord(result.project.timelineViewState); + expect(timelineViewState.playheadTime).toBe(301_235); + expect(timelineViewState.zoomLevel).toBe(1.25); + expect(timelineViewState.scrollLeft).toBe(120); + + const scenes = asRecordArray(result.project.scenes); + const scene = scenes[0]; + expect(scene.bookmarks).toEqual([ + { + time: 120_000, + duration: 60_001, + note: "Marker", + color: "#ff0000", + }, + ]); + + const tracks = asRecord(scene.tracks); + const mainTrack = asRecord(tracks.main); + const elements = asRecordArray(mainTrack.elements); + const element = elements[0]; + expect(element.startTime).toBe(300_000); + expect(element.duration).toBe(2_152_466); + expect(element.trimStart).toBe(30_000); + expect(element.trimEnd).toBe(15_001); + expect(element.sourceDuration).toBe(2_197_468); + + const animations = asRecord(element.animations); + const channels = asRecord(animations.channels); + const opacityChannel = asRecord(channels.opacity); + expect(opacityChannel.keys).toEqual([ + { + id: "key-1", + time: 1_001, + value: 1, + segmentToNext: "bezier", + tangentMode: "flat", + leftHandle: { dt: -121, dv: 0 }, + rightHandle: { dt: 60, dv: 0 }, + }, + ]); + }); + + test("keeps already-integer media-time values unchanged", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v27-integer-time", + version: 27, + metadata: { + id: "project-v27-integer-time", + name: "Project", + duration: 120_000, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + timelineViewState: { + zoomLevel: 2, + scrollLeft: 300, + playheadTime: 30_000, + }, + scenes: [ + { + id: "scene-1", + bookmarks: [{ time: 60_000, duration: 15_000 }], + tracks: { + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [ + { + id: "element-1", + type: "video", + name: "Clip", + startTime: 10_000, + duration: 20_000, + trimStart: 1_000, + trimEnd: 2_000, + sourceDuration: 23_000, + mediaId: "media-1", + transform: { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + overlay: [], + audio: [], + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(28); + + const metadata = asRecord(result.project.metadata); + expect(metadata.duration).toBe(120_000); + + const timelineViewState = asRecord(result.project.timelineViewState); + expect(timelineViewState).toEqual({ + zoomLevel: 2, + scrollLeft: 300, + playheadTime: 30_000, + }); + + const scenes = asRecordArray(result.project.scenes); + const scene = scenes[0]; + expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]); + + const tracks = asRecord(scene.tracks); + const mainTrack = asRecord(tracks.main); + const element = asRecordArray(mainTrack.elements)[0]; + expect(element.startTime).toBe(10_000); + expect(element.duration).toBe(20_000); + expect(element.trimStart).toBe(1_000); + expect(element.trimEnd).toBe(2_000); + expect(element.sourceDuration).toBe(23_000); + }); + + test("skips projects already on v28", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v28", + version: 28, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v28"); + }); + + test("skips projects not on v27", () => { + const result = transformProjectV27ToV28({ + project: { + id: "project-v26", + version: 26, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v27"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts b/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts new file mode 100644 index 00000000..38a806e1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v28-to-v29.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV28ToV29 } from "../transformers/v28-to-v29"; +import { asRecord, asRecordArray } from "./helpers"; + +describe("V28 to V29 Migration", () => { + test("copies built-in element fields into params without deleting source fields", () => { + const result = transformProjectV28ToV29({ + project: { + id: "project-v28-builtins", + version: 28, + scenes: [ + { + id: "scene-1", + tracks: { + main: { + id: "main", + type: "video", + elements: [ + { + id: "video-1", + type: "video", + name: "Video", + transform: { + position: { x: 12, y: -4 }, + scaleX: 1.25, + scaleY: 0.75, + rotate: 9, + }, + opacity: 0.5, + blendMode: "multiply", + volume: -6, + muted: true, + }, + ], + }, + overlay: [ + { + id: "text-track", + type: "text", + elements: [ + { + id: "text-1", + type: "text", + name: "Text", + content: "Hello", + fontSize: 20, + fontFamily: "Inter", + color: "#eeeeee", + textAlign: "left", + fontWeight: "bold", + fontStyle: "italic", + textDecoration: "underline", + letterSpacing: 1.5, + lineHeight: 1.4, + background: { + enabled: true, + color: "#111111", + paddingX: 10, + paddingY: 12, + }, + transform: { + position: { x: 1, y: 2 }, + scaleX: 1, + scaleY: 1, + rotate: 0, + }, + opacity: 1, + }, + ], + }, + ], + audio: [], + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(29); + + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecord(scene.tracks); + const main = asRecord(tracks.main); + const video = asRecordArray(main.elements)[0]; + expect(video.transform).toEqual({ + position: { x: 12, y: -4 }, + scaleX: 1.25, + scaleY: 0.75, + rotate: 9, + }); + expect(video.opacity).toBe(0.5); + expect(video.volume).toBe(-6); + expect(video.muted).toBe(true); + expect(video.params).toEqual({ + "transform.positionX": 12, + "transform.positionY": -4, + "transform.scaleX": 1.25, + "transform.scaleY": 0.75, + "transform.rotate": 9, + opacity: 0.5, + blendMode: "multiply", + volume: -6, + muted: true, + }); + + const overlay = asRecordArray(tracks.overlay)[0]; + const text = asRecordArray(asRecord(overlay).elements)[0]; + expect(text.content).toBe("Hello"); + expect(text.background).toEqual({ + enabled: true, + color: "#111111", + paddingX: 10, + paddingY: 12, + }); + expect(text.params).toMatchObject({ + content: "Hello", + fontSize: 20, + fontFamily: "Inter", + color: "#eeeeee", + textAlign: "left", + fontWeight: "bold", + fontStyle: "italic", + textDecoration: "underline", + letterSpacing: 1.5, + lineHeight: 1.4, + "background.enabled": true, + "background.color": "#111111", + "background.paddingX": 10, + "background.paddingY": 12, + "transform.positionX": 1, + "transform.positionY": 2, + }); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts new file mode 100644 index 00000000..c68f8dce --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV29ToV30 } from "../transformers/v29-to-v30"; +import { asRecord, asRecordArray } from "./helpers"; + +describe("V29 to V30 Migration", () => { + test("flattens animation bindings and channels by path", () => { + const result = transformProjectV29ToV30({ + project: { + id: "project-v29-animations", + version: 29, + scenes: [ + { + id: "scene-1", + tracks: { + main: { + id: "main", + type: "video", + elements: [ + { + id: "text-1", + type: "text", + animations: { + bindings: { + opacity: { + path: "opacity", + kind: "number", + components: [ + { key: "value", channelId: "opacity:value" }, + ], + }, + color: { + path: "color", + kind: "color", + colorSpace: "srgb-linear", + components: [ + { key: "r", channelId: "color:r" }, + { key: "g", channelId: "color:g" }, + { key: "b", channelId: "color:b" }, + { key: "a", channelId: "color:a" }, + ], + }, + "params.label": { + path: "params.label", + kind: "discrete", + components: [ + { + key: "value", + channelId: "params.label:value", + }, + ], + }, + }, + channels: { + "opacity:value": { + kind: "scalar", + keys: [{ id: "o1", time: 0, value: 0.5 }], + }, + "color:r": { + kind: "scalar", + keys: [{ id: "c1", time: 0, value: 1 }], + }, + "color:g": { + kind: "scalar", + keys: [{ id: "c1", time: 0, value: 0.5 }], + }, + "color:b": { + kind: "scalar", + keys: [{ id: "c1", time: 0, value: 0 }], + }, + "color:a": { + kind: "scalar", + keys: [{ id: "c1", time: 0, value: 1 }], + }, + "params.label:value": { + kind: "discrete", + keys: [{ id: "d1", time: 0, value: "Title" }], + }, + }, + }, + }, + ], + }, + overlay: [], + audio: [], + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(30); + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecord(scene.tracks); + const main = asRecord(tracks.main); + const element = asRecordArray(main.elements)[0]; + const animations = asRecord(element.animations); + expect(animations.bindings).toBeDefined(); + expect(animations.channels).toBeDefined(); + expect(animations).toMatchObject({ + opacity: { + keys: [{ id: "o1", time: 0, value: 0.5 }], + }, + color: { + r: { keys: [{ id: "c1", time: 0, value: 1 }] }, + g: { keys: [{ id: "c1", time: 0, value: 0.5 }] }, + b: { keys: [{ id: "c1", time: 0, value: 0 }] }, + a: { keys: [{ id: "c1", time: 0, value: 1 }] }, + }, + "params.label": { + keys: [{ id: "d1", time: 0, value: "Title" }], + }, + }); + }); + + test("leaves elements without animations unchanged", () => { + const result = transformProjectV29ToV30({ + project: { + id: "project-v29-no-animations", + version: 29, + scenes: [ + { + tracks: { + main: { + elements: [{ id: "image-1", type: "image" }], + }, + }, + }, + ], + }, + }); + + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecord(scene.tracks); + const main = asRecord(tracks.main); + const element = asRecordArray(main.elements)[0]; + expect(element).toEqual({ id: "image-1", type: "image" }); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts index 6c2d6597..e5ad7e34 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV3ToV4 } from "../transformers/v3-to-v4";
import { v3Project } from "./fixtures";
+import { asRecordArray } from "./helpers";
describe("V3 to V4 Migration", () => {
test("normalizes legacy text fontWeight values", () => {
@@ -54,15 +55,9 @@ describe("V3 to V4 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(4);
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.fontWeight).toBe("700");
});
@@ -104,15 +99,9 @@ describe("V3 to V4 Migration", () => { };
const result = transformProjectV3ToV4({ project: projectWithoutTextTrack });
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.iconName).toBe("mdi:home");
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts b/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts new file mode 100644 index 00000000..65a1ffed --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v30-to-v31.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV30ToV31 } from "../transformers/v30-to-v31"; +import { asRecord, asRecordArray } from "./helpers"; + +describe("V30 to V31 Migration", () => { + test("renames custom masks to freeform without changing params", () => { + const params = { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + path: [{ id: "p1", x: 0, y: 0, inX: 0, inY: 0, outX: 0, outY: 0 }], + closed: true, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + }; + + const result = transformProjectV30ToV31({ + project: { + id: "project-v30-freeform", + version: 30, + scenes: [ + { + tracks: { + main: { + elements: [ + { + id: "image-1", + type: "image", + masks: [{ id: "mask-1", type: "custom", params }], + }, + ], + }, + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(31); + const scene = asRecordArray(result.project.scenes)[0]; + const tracks = asRecord(scene.tracks); + const main = asRecord(tracks.main); + const element = asRecordArray(main.elements)[0]; + const mask = asRecordArray(asRecord(element).masks)[0]; + expect(mask).toMatchObject({ + id: "mask-1", + type: "freeform", + params, + }); + expect(asRecord(mask).legacyType).toBeUndefined(); + }); + + test("leaves a type:freeform mask in a v30 project unchanged", () => { + const result = transformProjectV30ToV31({ + project: { + id: "project-v30-already-freeform", + version: 30, + scenes: [ + { + tracks: { + main: { + elements: [ + { + id: "elem-1", + masks: [{ id: "m1", type: "freeform", params: {} }], + }, + ], + }, + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + const scene = asRecordArray(result.project.scenes)[0]; + const main = asRecord(asRecord(scene.tracks).main); + const element = asRecordArray(main.elements)[0]; + expect(asRecordArray(asRecord(element).masks)[0]).toMatchObject({ + id: "m1", + type: "freeform", + }); + }); + + test("leaves elements without a masks array unchanged", () => { + const result = transformProjectV30ToV31({ + project: { + id: "project-v30-no-masks", + version: 30, + scenes: [ + { + tracks: { + main: { + elements: [{ id: "elem-1", type: "video" }], + }, + }, + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + const scene = asRecordArray(result.project.scenes)[0]; + const main = asRecord(asRecord(scene.tracks).main); + const element = asRecordArray(main.elements)[0]; + expect(element).toMatchObject({ id: "elem-1", type: "video" }); + }); + + test("skips a project that is already v31", () => { + const project = { id: "p1", version: 31, scenes: [] }; + const result = transformProjectV30ToV31({ project }); + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v31"); + expect(result.project).toBe(project); + }); + + test("skips a project that is not v30", () => { + const project = { id: "p1", version: 29, scenes: [] }; + const result = transformProjectV30ToV31({ project }); + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v30"); + expect(result.project).toBe(project); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts index 95386137..16e9e7ca 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV4ToV5 } from "../transformers/v4-to-v5";
import { v3Project } from "./fixtures";
+import { asRecordArray } from "./helpers";
describe("V4 to V5 Migration", () => {
test("migrates sticker iconName to stickerId and removes legacy color", () => {
@@ -48,15 +49,9 @@ describe("V4 to V5 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(5);
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.stickerId).toBe("icons:mdi:home");
expect("iconName" in migratedElement).toBe(false);
@@ -101,15 +96,9 @@ describe("V4 to V5 Migration", () => { };
const result = transformProjectV4ToV5({ project: projectWithStickerId });
- const migratedScene = (
- result.project.scenes as Array<Record<string, unknown>>
- )[0];
- const migratedTrack = (
- migratedScene.tracks as Array<Record<string, unknown>>
- )[0];
- const migratedElement = (
- migratedTrack.elements as Array<Record<string, unknown>>
- )[0];
+ const migratedScene = asRecordArray(result.project.scenes)[0];
+ const migratedTrack = asRecordArray(migratedScene.tracks)[0];
+ const migratedElement = asRecordArray(migratedTrack.elements)[0];
expect(migratedElement.stickerId).toBe("flags:AD");
});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts index b05d1837..8fd267fd 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; import { v5Project } from "./fixtures"; +import { asRecordArray } from "./helpers"; describe("V5 to V6 Migration", () => { test("converts number bookmarks to Bookmark objects", async () => { @@ -13,19 +14,13 @@ describe("V5 to V6 Migration", () => { expect(result.skipped).toBe(false); expect(result.project.version).toBe(6); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ + const scenes = asRecordArray(result.project.scenes); + expect(scenes[0].bookmarks).toEqual([ { time: 2.0 }, { time: 5.5 }, { time: 12.0 }, ]); - - const introScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[1]; - expect(introScene.bookmarks).toEqual([]); + expect(scenes[1].bookmarks).toEqual([]); }); test("skips projects that are already v6", () => { @@ -83,9 +78,7 @@ describe("V5 to V6 Migration", () => { }); expect(result.skipped).toBe(false); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; + const mainScene = asRecordArray(result.project.scenes)[0]; expect(mainScene.bookmarks).toEqual([ { time: 1, note: "Intro", color: "#ef4444" }, { time: 5.5, duration: 2 }, diff --git a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts index aa79a6ea..ce8e9bd3 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v8-to-v9.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test";
import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
+import { asRecord, asRecordArray } from "./helpers";
const v8ProjectWithText = {
id: "project-v8-text",
@@ -70,16 +71,16 @@ describe("V8 to V9 Migration", () => { expect(result.skipped).toBe(false);
expect(result.project.version).toBe(9);
- const track = (
- result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
- )[0].tracks[0];
- const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
+ const track = asRecordArray(asRecordArray(result.project.scenes)[0].tracks)[0];
+ const elements = asRecordArray(track.elements);
+ const background0 = asRecord(elements[0].background);
+ const background1 = asRecord(elements[1].background);
- expect(elements[0].background.enabled).toBe(true);
- expect(elements[0].background.color).toBe("#ff0000");
+ expect(background0.enabled).toBe(true);
+ expect(background0.color).toBe("#ff0000");
- expect(elements[1].background.enabled).toBe(false);
- expect(elements[1].background.color).toBe("transparent");
+ expect(background1.enabled).toBe(false);
+ expect(background1.color).toBe("transparent");
});
test("preserves existing background.enabled if already present", () => {
@@ -87,7 +88,7 @@ describe("V8 to V9 Migration", () => { ...v8ProjectWithText,
scenes: [
{
- ...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
+ ...asRecordArray(v8ProjectWithText.scenes)[0],
tracks: [
{
id: "track-text",
@@ -116,10 +117,9 @@ describe("V8 to V9 Migration", () => { const result = transformProjectV8ToV9({ project: projectWithEnabled });
expect(result.skipped).toBe(false);
- const elements = (
- result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
- )[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
- expect(elements[0].background.enabled).toBe(false);
+ const track = asRecordArray(asRecordArray(result.project.scenes)[0].tracks)[0];
+ const elements = asRecordArray(track.elements);
+ expect(asRecord(elements[0].background).enabled).toBe(false);
});
test("skips non-text elements and tracks", () => {
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index b290b713..cbc35bb3 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -26,10 +26,14 @@ import { V23toV24Migration } from "./v23-to-v24"; import { V24toV25Migration } from "./v24-to-v25"; import { V25toV26Migration } from "./v25-to-v26"; import { V26toV27Migration } from "./v26-to-v27"; +import { V27toV28Migration } from "./v27-to-v28"; +import { V28toV29Migration } from "./v28-to-v29"; +import { V29toV30Migration } from "./v29-to-v30"; +import { V30toV31Migration } from "./v30-to-v31"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 27; +export const CURRENT_PROJECT_VERSION = 31; export const migrations = [ new V0toV1Migration(), @@ -59,4 +63,8 @@ export const migrations = [ new V24toV25Migration(), new V25toV26Migration(), new V26toV27Migration(), + new V27toV28Migration(), + new V28toV29Migration(), + new V29toV30Migration(), + new V30toV31Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts index c1ae894f..ace19354 100644 --- a/apps/web/src/services/storage/migrations/transformers/index.ts +++ b/apps/web/src/services/storage/migrations/transformers/index.ts @@ -4,4 +4,5 @@ export { transformProjectV2ToV3 } from "./v2-to-v3"; export { transformProjectV3ToV4 } from "./v3-to-v4"; export { transformProjectV4ToV5 } from "./v4-to-v5"; export { transformProjectV17ToV18 } from "./v17-to-v18"; +export { transformProjectV28ToV29 } from "./v28-to-v29"; export type { MigrationResult, ProjectRecord } from "./types"; diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index 065a6b34..e9e23c4e 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -19,34 +19,6 @@ const EMPTY_V1_TO_V2_CONTEXT: V1ToV2Context = { mediaTypesById: {}, }; -interface LegacyMediaElement { - type: "media"; - mediaId: string; - muted?: boolean; - [key: string]: unknown; -} - -interface LegacyTextElement { - type: "text"; - x: number; - y: number; - rotation: number; - opacity: number; - [key: string]: unknown; -} - -interface LegacyAudioElement { - type: "audio"; - mediaId: string; - [key: string]: unknown; -} - -interface LegacyMediaTrack { - type: "media"; - elements: unknown[]; - [key: string]: unknown; -} - interface V2Transform { scale: number; position: { x: number; y: number }; @@ -315,7 +287,7 @@ function transformTracks({ const isMain = !isFirstVideoTrackFound; isFirstVideoTrackFound = true; const videoTrack = transformMediaTrack({ - track: track as LegacyMediaTrack, + track, context, isMain, }); @@ -343,7 +315,7 @@ function transformMediaTrack({ context, isMain, }: { - track: LegacyMediaTrack; + track: Record<string, unknown>; context: V1ToV2Context; isMain: boolean; }): V2VideoTrack { @@ -354,8 +326,7 @@ function transformMediaTrack({ return null; } - const mediaElement = element as LegacyMediaElement; - const mediaId = getStringValue({ value: mediaElement.mediaId }); + const mediaId = getStringValue({ value: element.mediaId }); if (!mediaId) { return null; } @@ -372,7 +343,7 @@ function transformMediaTrack({ rotate: 0, }; - const muted = mediaElement.muted === true; + const muted = element.muted === true; if (mediaType === "image") { const imageElement: V2ImageElement = { @@ -442,15 +413,14 @@ function transformTextTrack({ return null; } - const textElement = element as LegacyTextElement; - const x = getNumberValue({ value: textElement.x, fallback: 0 }); - const y = getNumberValue({ value: textElement.y, fallback: 0 }); + const x = getNumberValue({ value: element.x, fallback: 0 }); + const y = getNumberValue({ value: element.y, fallback: 0 }); const rotation = getNumberValue({ - value: textElement.rotation, + value: element.rotation, fallback: 0, }); const opacity = getNumberValue({ - value: textElement.opacity, + value: element.opacity, fallback: 1, }); @@ -464,23 +434,23 @@ function transformTextTrack({ id: getStringValue({ value: element.id, fallback: "" }), name: getStringValue({ value: element.name, fallback: "" }), type: "text", - content: getStringValue({ value: textElement.content, fallback: "" }), + content: getStringValue({ value: element.content, fallback: "" }), fontSize: getNumberValue({ - value: textElement.fontSize, + value: element.fontSize, fallback: 16, }), fontFamily: getStringValue({ - value: textElement.fontFamily, + value: element.fontFamily, fallback: "Arial", }), color: getStringValue({ - value: textElement.color, + value: element.color, fallback: "#000000", }), background: { enabled: false, color: getStringValue({ - value: textElement.backgroundColor, + value: element.backgroundColor, fallback: "transparent", }), cornerRadius: 0, @@ -489,22 +459,26 @@ function transformTextTrack({ offsetX: 0, offsetY: 0, }, - textAlign: (getStringValue({ - value: textElement.textAlign, + textAlign: parseEnum({ + value: element.textAlign, + allowed: ["left", "center", "right"] as const, fallback: "left", - }) || "left") as "left" | "center" | "right", - fontWeight: (getStringValue({ - value: textElement.fontWeight, + }), + fontWeight: parseEnum({ + value: element.fontWeight, + allowed: ["normal", "bold"] as const, fallback: "normal", - }) || "normal") as "normal" | "bold", - fontStyle: (getStringValue({ - value: textElement.fontStyle, + }), + fontStyle: parseEnum({ + value: element.fontStyle, + allowed: ["normal", "italic"] as const, fallback: "normal", - }) || "normal") as "normal" | "italic", - textDecoration: (getStringValue({ - value: textElement.textDecoration, + }), + textDecoration: parseEnum({ + value: element.textDecoration, + allowed: ["none", "underline", "line-through"] as const, fallback: "none", - }) || "none") as "none" | "underline" | "line-through", + }), hidden: false, transform, opacity, @@ -538,8 +512,7 @@ function transformAudioTrack({ return null; } - const audioElement = element as LegacyAudioElement; - const mediaId = getStringValue({ value: audioElement.mediaId }); + const mediaId = getStringValue({ value: element.mediaId }); if (!mediaId) { return null; } @@ -762,6 +735,23 @@ function getStringValue({ return fallback; } +function parseEnum<T extends string>({ + value, + allowed, + fallback, +}: { + value: unknown; + allowed: readonly T[]; + fallback: T; +}): T { + for (const candidate of allowed) { + if (value === candidate) { + return candidate; + } + } + return fallback; +} + function normalizeDateString({ value }: { value: unknown }): string { if (value instanceof Date) { return value.toISOString(); diff --git a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts index 89dde38e..6e152adb 100644 --- a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts +++ b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts @@ -94,10 +94,7 @@ function migrateSplitMask({ mask }: { mask: unknown }): unknown { const x = (position - 0.5) * Math.cos(angleRad);
const y = (position - 0.5) * Math.sin(angleRad);
- const { position: _removed, ...restParams } = params as Record<
- string,
- unknown
- > & { position: unknown };
+ const { position: _removed, ...restParams } = params;
return {
...mask,
diff --git a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts index 3b118ee7..ccebb980 100644 --- a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts +++ b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts @@ -1,7 +1,7 @@ import type { MigrationResult, ProjectRecord } from "./types"; import { getProjectId, isRecord } from "./utils"; -interface CustomMaskPathPoint { +interface FreeformPathPoint { id: string; x: number; y: number; @@ -11,35 +11,34 @@ interface CustomMaskPathPoint { outY: number; } -function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { - if (!value || typeof value !== "object") { +function isFreeformPathPoint(value: unknown): value is FreeformPathPoint { + if (!isRecord(value)) { return false; } - const candidate = value as Record<string, unknown>; return ( - typeof candidate.id === "string" && - typeof candidate.x === "number" && - typeof candidate.y === "number" && - typeof candidate.inX === "number" && - typeof candidate.inY === "number" && - typeof candidate.outX === "number" && - typeof candidate.outY === "number" + typeof value.id === "string" && + typeof value.x === "number" && + typeof value.y === "number" && + typeof value.inX === "number" && + typeof value.inY === "number" && + typeof value.outX === "number" && + typeof value.outY === "number" ); } -function parseCustomMaskPath({ +function parseFreeformPath({ path, }: { path: string; -}): CustomMaskPathPoint[] { +}): FreeformPathPoint[] { if (!path) { return []; } try { const parsed = JSON.parse(path); - return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; + return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : []; } catch { return []; } @@ -151,7 +150,7 @@ function migrateMask({ mask }: { mask: unknown }): unknown { ...mask, params: { ...mask.params, - path: parseCustomMaskPath({ path }), + path: parseFreeformPath({ path }), }, }; } diff --git a/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts new file mode 100644 index 00000000..fc8d8432 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v27-to-v28.ts @@ -0,0 +1,228 @@ +import { roundMediaTime } from "@/wasm"; +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV27ToV28({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 28) { + return { project, skipped: true, reason: "already v28" }; + } + if (version !== 27) { + return { project, skipped: true, reason: "not v27" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 28, + }, + skipped: false, + }; +} + +function migrateProject({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const nextProject = { ...project }; + + if (isRecord(project.metadata)) { + nextProject.metadata = migrateTimeFields({ + record: project.metadata, + keys: ["duration"], + }); + } + + if (isRecord(project.timelineViewState)) { + nextProject.timelineViewState = migrateTimeFields({ + record: project.timelineViewState, + keys: ["playheadTime"], + }); + } + + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + + if (Array.isArray(scene.bookmarks)) { + nextScene.bookmarks = scene.bookmarks.map((bookmark) => + migrateBookmark({ bookmark }), + ); + } + + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...tracks }; + + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + + return nextTracks; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) { + return element; + } + + const nextElement = migrateTimeFields({ + record: element, + keys: ["duration", "startTime", "trimStart", "trimEnd", "sourceDuration"], + }); + + if (isRecord(element.animations)) { + nextElement.animations = migrateAnimations({ + animations: element.animations, + }); + } + + return nextElement; +} + +function migrateAnimations({ + animations, +}: { + animations: ProjectRecord; +}): ProjectRecord { + if (!isRecord(animations.channels)) { + return animations; + } + + return { + ...animations, + channels: Object.fromEntries( + Object.entries(animations.channels).map(([channelId, channel]) => [ + channelId, + migrateAnimationChannel({ channel }), + ]), + ), + }; +} + +function migrateAnimationChannel({ channel }: { channel: unknown }): unknown { + if (!isRecord(channel) || !Array.isArray(channel.keys)) { + return channel; + } + + return { + ...channel, + keys: channel.keys.map((keyframe) => migrateAnimationKeyframe({ keyframe })), + }; +} + +function migrateAnimationKeyframe({ + keyframe, +}: { + keyframe: unknown; +}): unknown { + if (!isRecord(keyframe)) { + return keyframe; + } + + const nextKeyframe = migrateTimeFields({ + record: keyframe, + keys: ["time"], + }); + + if (isRecord(keyframe.leftHandle)) { + nextKeyframe.leftHandle = migrateTimeFields({ + record: keyframe.leftHandle, + keys: ["dt"], + }); + } + + if (isRecord(keyframe.rightHandle)) { + nextKeyframe.rightHandle = migrateTimeFields({ + record: keyframe.rightHandle, + keys: ["dt"], + }); + } + + return nextKeyframe; +} + +function migrateBookmark({ bookmark }: { bookmark: unknown }): unknown { + if (!isRecord(bookmark)) { + return bookmark; + } + + return migrateTimeFields({ + record: bookmark, + keys: ["time", "duration"], + }); +} + +function migrateTimeFields({ + record, + keys, +}: { + record: ProjectRecord; + keys: string[]; +}): ProjectRecord { + const nextRecord = { ...record }; + + for (const key of keys) { + if (!(key in record)) { + continue; + } + + nextRecord[key] = normalizeMediaTimeValue({ value: record[key] }); + } + + return nextRecord; +} + +function normalizeMediaTimeValue({ value }: { value: unknown }): unknown { + if (typeof value !== "number" || !Number.isFinite(value)) { + return value; + } + return roundMediaTime({ time: value }); +} diff --git a/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts b/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts new file mode 100644 index 00000000..4ce048f6 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v28-to-v29.ts @@ -0,0 +1,226 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV28ToV29({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 29) { + return { project, skipped: true, reason: "already v29" }; + } + if (version !== 28) { + return { project, skipped: true, reason: "not v28" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 29, + }, + skipped: false, + }; +} + +function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord { + const nextProject = { ...project }; + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...tracks }; + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + return nextTracks; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) { + return element; + } + + const nextElement = { ...element }; + const params: ProjectRecord = isRecord(element.params) + ? { ...element.params } + : {}; + + copyTransformParams({ source: element, params }); + copyPrimitiveParam({ source: element, params, sourceKey: "opacity", paramKey: "opacity" }); + copyPrimitiveParam({ + source: element, + params, + sourceKey: "blendMode", + paramKey: "blendMode", + }); + + if (element.type === "audio" || element.type === "video") { + copyPrimitiveParam({ + source: element, + params, + sourceKey: "volume", + paramKey: "volume", + }); + copyPrimitiveParam({ + source: element, + params, + sourceKey: "muted", + paramKey: "muted", + }); + } + + if (element.type === "text") { + copyTextParams({ source: element, params }); + } + + nextElement.params = params; + return nextElement; +} + +function copyTransformParams({ + source, + params, +}: { + source: ProjectRecord; + params: ProjectRecord; +}): void { + if (!isRecord(source.transform)) { + return; + } + if (isRecord(source.transform.position)) { + copyPrimitiveParam({ + source: source.transform.position, + params, + sourceKey: "x", + paramKey: "transform.positionX", + }); + copyPrimitiveParam({ + source: source.transform.position, + params, + sourceKey: "y", + paramKey: "transform.positionY", + }); + } + copyPrimitiveParam({ + source: source.transform, + params, + sourceKey: "scaleX", + paramKey: "transform.scaleX", + }); + copyPrimitiveParam({ + source: source.transform, + params, + sourceKey: "scaleY", + paramKey: "transform.scaleY", + }); + copyPrimitiveParam({ + source: source.transform, + params, + sourceKey: "rotate", + paramKey: "transform.rotate", + }); +} + +function copyTextParams({ + source, + params, +}: { + source: ProjectRecord; + params: ProjectRecord; +}): void { + for (const key of [ + "content", + "fontSize", + "fontFamily", + "color", + "textAlign", + "fontWeight", + "fontStyle", + "textDecoration", + "letterSpacing", + "lineHeight", + ]) { + copyPrimitiveParam({ source, params, sourceKey: key, paramKey: key }); + } + + if (!isRecord(source.background)) { + return; + } + for (const key of [ + "enabled", + "color", + "cornerRadius", + "paddingX", + "paddingY", + "offsetX", + "offsetY", + ]) { + copyPrimitiveParam({ + source: source.background, + params, + sourceKey: key, + paramKey: `background.${key}`, + }); + } +} + +function copyPrimitiveParam({ + source, + params, + sourceKey, + paramKey, +}: { + source: ProjectRecord; + params: ProjectRecord; + sourceKey: string; + paramKey: string; +}): void { + const value = source[sourceKey]; + if ( + typeof value === "number" || + typeof value === "string" || + typeof value === "boolean" + ) { + params[paramKey] = value; + } +} diff --git a/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts new file mode 100644 index 00000000..f3050742 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts @@ -0,0 +1,138 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV29ToV30({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 30) { + return { project, skipped: true, reason: "already v30" }; + } + if (version !== 29) { + return { project, skipped: true, reason: "not v29" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 30, + }, + skipped: false, + }; +} + +function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord { + const nextProject = { ...project }; + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...tracks }; + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + return nextTracks; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element) || !isRecord(element.animations)) { + return element; + } + + return { + ...element, + animations: migrateAnimations({ animations: element.animations }), + }; +} + +function migrateAnimations({ + animations, +}: { + animations: ProjectRecord; +}): ProjectRecord { + if (!isRecord(animations.bindings) || !isRecord(animations.channels)) { + return animations; + } + + const channels = animations.channels; + const nextAnimations: ProjectRecord = { ...animations }; + for (const binding of Object.values(animations.bindings)) { + if (!isRecord(binding) || typeof binding.path !== "string") { + continue; + } + if (!Array.isArray(binding.components)) { + continue; + } + + const componentEntries = binding.components.flatMap((component) => { + if ( + !isRecord(component) || + typeof component.key !== "string" || + typeof component.channelId !== "string" + ) { + return []; + } + const channel = channels[component.channelId]; + if (!isRecord(channel)) { + return []; + } + return [[component.key, migrateChannel({ channel })] as const]; + }); + if (componentEntries.length === 0) { + continue; + } + + nextAnimations[binding.path] = + componentEntries.length === 1 && componentEntries[0]?.[0] === "value" + ? componentEntries[0][1] + : Object.fromEntries(componentEntries); + } + + return nextAnimations; +} + +function migrateChannel({ channel }: { channel: ProjectRecord }): ProjectRecord { + const { kind: _kind, ...nextChannel } = channel; + return nextChannel; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts index 84d97fd8..7a28134b 100644 --- a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts +++ b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts @@ -1,10 +1,10 @@ import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
-const LEGACY_FONT_WEIGHT_MAP = {
- normal: "400",
- bold: "700",
-} as const;
+const LEGACY_FONT_WEIGHT_MAP = new Map<string, string>([
+ ["normal", "400"],
+ ["bold", "700"],
+]);
const VALID_NUMERIC_FONT_WEIGHTS = new Set([
"100",
@@ -168,10 +168,9 @@ function normalizeFontWeight({ value }: { value: unknown }): unknown { }
const normalized = value.trim().toLowerCase();
- if (normalized in LEGACY_FONT_WEIGHT_MAP) {
- return LEGACY_FONT_WEIGHT_MAP[
- normalized as keyof typeof LEGACY_FONT_WEIGHT_MAP
- ];
+ const mapped = LEGACY_FONT_WEIGHT_MAP.get(normalized);
+ if (mapped !== undefined) {
+ return mapped;
}
if (VALID_NUMERIC_FONT_WEIGHTS.has(normalized)) {
diff --git a/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts b/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts new file mode 100644 index 00000000..512fc921 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v30-to-v31.ts @@ -0,0 +1,100 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV30ToV31({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 31) { + return { project, skipped: true, reason: "already v31" }; + } + if (version !== 30) { + return { project, skipped: true, reason: "not v30" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 31, + }, + skipped: false, + }; +} + +function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord { + const nextProject = { ...project }; + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + return nextProject; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + if (isRecord(scene.tracks)) { + nextScene.tracks = migrateTracks({ tracks: scene.tracks }); + } + return nextScene; +} + +function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord { + const nextTracks = { ...tracks }; + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track })); + } + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + return nextTracks; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element) || !Array.isArray(element.masks)) { + return element; + } + + return { + ...element, + masks: element.masks.map((mask) => migrateMask({ mask })), + }; +} + +function migrateMask({ mask }: { mask: unknown }): unknown { + if (!isRecord(mask) || mask.type !== "custom") { + return mask; + } + + // "custom" is the only freeform-path mask type; a down-migration can map + // type === "freeform" back to "custom" without any additional marker. + return { + ...mask, + type: "freeform", + }; +} diff --git a/apps/web/src/services/storage/migrations/v27-to-v28.ts b/apps/web/src/services/storage/migrations/v27-to-v28.ts new file mode 100644 index 00000000..aaec6256 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v27-to-v28.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV27ToV28 } from "./transformers/v27-to-v28"; + +export class V27toV28Migration extends StorageMigration { + from = 27; + to = 28; + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + return transformProjectV27ToV28({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v28-to-v29.ts b/apps/web/src/services/storage/migrations/v28-to-v29.ts new file mode 100644 index 00000000..a2e75ec0 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v28-to-v29.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV28ToV29 } from "./transformers/v28-to-v29"; + +export class V28toV29Migration extends StorageMigration { + from = 28; + to = 29; + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + return transformProjectV28ToV29({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v29-to-v30.ts b/apps/web/src/services/storage/migrations/v29-to-v30.ts new file mode 100644 index 00000000..0b9c23b9 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v29-to-v30.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV29ToV30 } from "./transformers/v29-to-v30"; + +export class V29toV30Migration extends StorageMigration { + from = 29; + to = 30; + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + return transformProjectV29ToV30({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v30-to-v31.ts b/apps/web/src/services/storage/migrations/v30-to-v31.ts new file mode 100644 index 00000000..35bf0969 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v30-to-v31.ts @@ -0,0 +1,14 @@ +import { StorageMigration, type StorageMigrationRunArgs } from "./base"; +import type { MigrationResult, ProjectRecord } from "./transformers/types"; +import { transformProjectV30ToV31 } from "./transformers/v30-to-v31"; + +export class V30toV31Migration extends StorageMigration { + from = 30; + to = 31; + + async run({ + project, + }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> { + return transformProjectV30ToV31({ project }); + } +} diff --git a/apps/web/src/services/storage/opfs-adapter.ts b/apps/web/src/services/storage/opfs-adapter.ts index 879223b5..220ee452 100644 --- a/apps/web/src/services/storage/opfs-adapter.ts +++ b/apps/web/src/services/storage/opfs-adapter.ts @@ -27,7 +27,13 @@ export class OPFSAdapter implements StorageAdapter<File> { } } - async set(key: string, file: File): Promise<void> { + async set({ + key, + value: file, + }: { + key: string; + value: File; + }): Promise<void> { const directory = await this.getDirectory(); const fileHandle = await directory.getFileHandle(key, { create: true }); const writable = await fileHandle.createWritable(); diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index 858198eb..e8fc26c0 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -22,12 +22,15 @@ import { runStorageMigrations, } from "@/services/storage/migrations"; import type { Bookmark, SceneTracks, TScene } from "@/timeline"; +import { roundMediaTime } from "@/wasm"; function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { if (!Array.isArray(raw)) return []; return raw .map((item): Bookmark | null => { - if (typeof item === "number") return { time: item }; + if (typeof item === "number") { + return { time: roundMediaTime({ time: item }) }; + } const obj = item as Record<string, unknown>; if ( typeof obj !== "object" || @@ -37,10 +40,12 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { return null; } return { - time: obj.time, + time: roundMediaTime({ time: obj.time }), ...(typeof obj.note === "string" && { note: obj.note }), ...(typeof obj.color === "string" && { color: obj.color }), - ...(typeof obj.duration === "number" && { duration: obj.duration }), + ...(typeof obj.duration === "number" && { + duration: roundMediaTime({ time: obj.duration }), + }), }; }) .filter((b): b is Bookmark => b !== null); @@ -60,17 +65,17 @@ class StorageService { version: 1, }; - this.projectsAdapter = new IndexedDBAdapter<SerializedProject>( - this.config.projectsDb, - "projects", - this.config.version, - ); + this.projectsAdapter = new IndexedDBAdapter<SerializedProject>({ + dbName: this.config.projectsDb, + storeName: "projects", + version: this.config.version, + }); - this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>( - this.config.savedSoundsDb, - "saved-sounds", - this.config.version, - ); + this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>({ + dbName: this.config.savedSoundsDb, + storeName: "saved-sounds", + version: this.config.version, + }); } private async ensureMigrations(): Promise<void> { @@ -86,11 +91,11 @@ class StorageService { } private getProjectMediaAdapters({ projectId }: { projectId: string }) { - const mediaMetadataAdapter = new IndexedDBAdapter<MediaAssetData>( - `${this.config.mediaDb}-${projectId}`, - "media-metadata", - this.config.version, - ); + const mediaMetadataAdapter = new IndexedDBAdapter<MediaAssetData>({ + dbName: `${this.config.mediaDb}-${projectId}`, + storeName: "media-metadata", + version: this.config.version, + }); const mediaAssetsAdapter = new OPFSAdapter(`media-files-${projectId}`); @@ -156,7 +161,10 @@ class StorageService { timelineViewState: project.timelineViewState, }; - await this.projectsAdapter.set(project.metadata.id, serializedProject); + await this.projectsAdapter.set({ + key: project.metadata.id, + value: serializedProject, + }); } async loadProject({ @@ -198,9 +206,11 @@ class StorageService { id: serializedProject.metadata.id, name: serializedProject.metadata.name, thumbnail: serializedProject.metadata.thumbnail, - duration: - serializedProject.metadata.duration ?? - getProjectDurationFromScenes({ scenes }), + duration: roundMediaTime({ + time: + serializedProject.metadata.duration ?? + getProjectDurationFromScenes({ scenes }), + }), createdAt: new Date(serializedProject.metadata.createdAt), updatedAt: new Date(serializedProject.metadata.updatedAt), }, @@ -253,11 +263,13 @@ class StorageService { id: serializedProject.metadata.id, name: serializedProject.metadata.name, thumbnail: serializedProject.metadata.thumbnail, - duration: - serializedProject.metadata.duration ?? - getProjectDurationFromScenes({ - scenes: (serializedProject.scenes ?? []) as unknown as TScene[], - }), + duration: roundMediaTime({ + time: + serializedProject.metadata.duration ?? + getProjectDurationFromScenes({ + scenes: (serializedProject.scenes ?? []) as unknown as TScene[], + }), + }), createdAt: new Date(serializedProject.metadata.createdAt), updatedAt: new Date(serializedProject.metadata.updatedAt), }); @@ -296,8 +308,14 @@ class StorageService { }; try { - await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file); - await mediaMetadataAdapter.set(mediaAsset.id, metadata); + await mediaAssetsAdapter.set({ + key: mediaAsset.id, + value: mediaAsset.file, + }); + await mediaMetadataAdapter.set({ + key: mediaAsset.id, + value: metadata, + }); } catch (error) { try { await mediaAssetsAdapter.remove(mediaAsset.id); @@ -492,7 +510,10 @@ class StorageService { lastModified: new Date().toISOString(), }; - await this.savedSoundsAdapter.set("user-sounds", updatedData); + await this.savedSoundsAdapter.set({ + key: "user-sounds", + value: updatedData, + }); } catch (error) { console.error("Failed to save sound effect:", error); throw error; @@ -508,7 +529,10 @@ class StorageService { lastModified: new Date().toISOString(), }; - await this.savedSoundsAdapter.set("user-sounds", updatedData); + await this.savedSoundsAdapter.set({ + key: "user-sounds", + value: updatedData, + }); } catch (error) { console.error("Failed to remove saved sound:", error); throw error; diff --git a/apps/web/src/services/storage/types.ts b/apps/web/src/services/storage/types.ts index 58c14b49..f20b311a 100644 --- a/apps/web/src/services/storage/types.ts +++ b/apps/web/src/services/storage/types.ts @@ -8,7 +8,7 @@ import type { TScene } from "@/timeline"; export interface StorageAdapter<T> { get(key: string): Promise<T | null>; - set(key: string, value: T): Promise<void>; + set(args: { key: string; value: T }): Promise<void>; remove(key: string): Promise<void>; list(): Promise<string[]>; clear(): Promise<void>; diff --git a/apps/web/src/sounds/sounds-store.ts b/apps/web/src/sounds/sounds-store.ts index b9b3f070..13ced962 100644 --- a/apps/web/src/sounds/sounds-store.ts +++ b/apps/web/src/sounds/sounds-store.ts @@ -4,6 +4,7 @@ import { storageService } from "@/services/storage/service"; import { toast } from "sonner"; import { EditorCore } from "@/core"; import { buildLibraryAudioElement } from "@/timeline/element-utils"; +import { mediaTimeFromSeconds } from "@/wasm"; interface SoundsStore { topSoundEffects: SoundEffect[]; @@ -227,7 +228,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({ const element = buildLibraryAudioElement({ sourceUrl: audioUrl, name: sound.name, - duration: sound.duration, + duration: mediaTimeFromSeconds({ seconds: sound.duration }), startTime: currentTime, buffer, }); diff --git a/apps/web/src/stickers/providers/index.ts b/apps/web/src/stickers/providers/index.ts index ca23963c..56b3f6cb 100644 --- a/apps/web/src/stickers/providers/index.ts +++ b/apps/web/src/stickers/providers/index.ts @@ -1,24 +1,24 @@ -import { stickersRegistry } from "../registry";
-import type { StickerProvider } from "@/stickers/types";
-import { flagsProvider } from "./flags";
-import { logosProvider } from "./logos";
-import { shapesProvider } from "./shapes";
-
-const defaultProviders: StickerProvider[] = [
- logosProvider,
- flagsProvider,
- shapesProvider,
-];
-
-export function registerDefaultStickerProviders({
- providersToRegister = defaultProviders,
-}: {
- providersToRegister?: StickerProvider[];
-} = {}): void {
- for (const provider of providersToRegister) {
- if (stickersRegistry.has(provider.id)) {
- continue;
- }
- stickersRegistry.register(provider.id, provider);
- }
-}
+import { stickersRegistry } from "../registry"; +import type { StickerProvider } from "@/stickers/types"; +import { flagsProvider } from "./flags"; +import { logosProvider } from "./logos"; +import { shapesProvider } from "./shapes"; + +const defaultProviders: StickerProvider[] = [ + logosProvider, + flagsProvider, + shapesProvider, +]; + +export function registerDefaultStickerProviders({ + providersToRegister = defaultProviders, +}: { + providersToRegister?: StickerProvider[]; +} = {}): void { + for (const provider of providersToRegister) { + if (stickersRegistry.has(provider.id)) { + continue; + } + stickersRegistry.register(provider.id, provider); + } +} diff --git a/apps/web/src/subtitles/build-subtitle-text-element.ts b/apps/web/src/subtitles/build-subtitle-text-element.ts index c4ac21ac..87cc6a03 100644 --- a/apps/web/src/subtitles/build-subtitle-text-element.ts +++ b/apps/web/src/subtitles/build-subtitle-text-element.ts @@ -5,8 +5,15 @@ import { setCanvasLetterSpacing, } from "@/text/layout"; import { DEFAULTS } from "@/timeline/defaults"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTimeFromSeconds } from "@/wasm"; import type { CreateTextElement } from "@/timeline"; +import type { TextBackground } from "@/text/background"; +import type { + TextAlign, + TextDecoration, + TextFontStyle, + TextFontWeight, +} from "@/text/primitives"; import type { SubtitleCue, SubtitleStyleOverrides } from "./types"; const SUBTITLE_MAX_WIDTH_RATIO = 0.8; @@ -89,8 +96,8 @@ function measureWrappedTextBlock({ ctx: CanvasRenderingContext2D; content: string; canvasHeight: number; - textAlign: CreateTextElement["textAlign"]; - background: CreateTextElement["background"]; + textAlign: TextAlign; + background: TextBackground; fontSize: number; lineHeight: number; }) { @@ -107,7 +114,7 @@ function measureWrappedTextBlock({ textAlign, block, background, - fontSizeRatio: fontSize / DEFAULTS.text.element.fontSize, + fontSizeRatio: fontSize / 15, }); return { @@ -124,13 +131,13 @@ function resolveSubtitleStyle({ fontFamily: string; fontSize: number; color: string; - textAlign: CreateTextElement["textAlign"]; - fontWeight: CreateTextElement["fontWeight"]; - fontStyle: CreateTextElement["fontStyle"]; - textDecoration: CreateTextElement["textDecoration"]; + textAlign: TextAlign; + fontWeight: TextFontWeight; + fontStyle: TextFontStyle; + textDecoration: TextDecoration; letterSpacing: number; lineHeight: number; - background: CreateTextElement["background"]; + background: TextBackground; placement: NonNullable<SubtitleStyleOverrides["placement"]>; } { const fontSize = @@ -139,18 +146,17 @@ function resolveSubtitleStyle({ : (style?.fontSize ?? SUBTITLE_FONT_SIZE); return { - fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily, + fontFamily: style?.fontFamily ?? "Arial", fontSize, - color: style?.color ?? DEFAULTS.text.element.color, + color: style?.color ?? "#ffffff", textAlign: style?.textAlign ?? "center", fontWeight: style?.fontWeight ?? "bold", - fontStyle: style?.fontStyle ?? DEFAULTS.text.element.fontStyle, - textDecoration: - style?.textDecoration ?? DEFAULTS.text.element.textDecoration, + fontStyle: style?.fontStyle ?? "normal", + textDecoration: style?.textDecoration ?? "none", letterSpacing: style?.letterSpacing ?? DEFAULTS.text.letterSpacing, lineHeight: style?.lineHeight ?? DEFAULTS.text.lineHeight, background: { - ...DEFAULTS.text.element.background, + ...DEFAULTS.text.background, enabled: false, ...(style?.background ?? {}), }, @@ -188,7 +194,7 @@ function resolvePositionX({ visualRect, }: { canvasWidth: number; - textAlign: CreateTextElement["textAlign"]; + textAlign: TextAlign; placement: ReturnType<typeof resolveSubtitleStyle>["placement"]; visualRect: { left: number; width: number }; }): number { @@ -309,25 +315,34 @@ export function buildSubtitleTextElement({ return { ...DEFAULTS.text.element, name: `Caption ${index + 1}`, - content, - duration: Math.round(caption.duration * TICKS_PER_SECOND), - startTime: Math.round(caption.startTime * TICKS_PER_SECOND), - fontSize: style.fontSize, - fontFamily: style.fontFamily, - color: style.color, - textAlign: style.textAlign, - fontWeight: style.fontWeight, - fontStyle: style.fontStyle, - textDecoration: style.textDecoration, - letterSpacing: style.letterSpacing, - lineHeight: style.lineHeight, - background: style.background, - transform: { - ...DEFAULTS.text.element.transform, - position: { - x: positionX, - y: positionY, - }, + duration: mediaTimeFromSeconds({ seconds: caption.duration }), + startTime: mediaTimeFromSeconds({ seconds: caption.startTime }), + params: { + ...DEFAULTS.text.element.params, + content, + fontSize: style.fontSize, + fontFamily: style.fontFamily, + color: style.color, + textAlign: style.textAlign, + fontWeight: style.fontWeight, + fontStyle: style.fontStyle, + textDecoration: style.textDecoration, + letterSpacing: style.letterSpacing, + lineHeight: style.lineHeight, + "background.enabled": style.background.enabled, + "background.color": style.background.color, + "background.cornerRadius": + style.background.cornerRadius ?? DEFAULTS.text.background.cornerRadius, + "background.paddingX": + style.background.paddingX ?? DEFAULTS.text.background.paddingX, + "background.paddingY": + style.background.paddingY ?? DEFAULTS.text.background.paddingY, + "background.offsetX": + style.background.offsetX ?? DEFAULTS.text.background.offsetX, + "background.offsetY": + style.background.offsetY ?? DEFAULTS.text.background.offsetY, + "transform.positionX": positionX, + "transform.positionY": positionY, }, }; } diff --git a/apps/web/src/subtitles/components/assets-view.tsx b/apps/web/src/subtitles/components/assets-view.tsx index 7e2ddad9..84480fa4 100644 --- a/apps/web/src/subtitles/components/assets-view.tsx +++ b/apps/web/src/subtitles/components/assets-view.tsx @@ -64,6 +64,7 @@ const IDLE_STATE: ProcessingState = { warnings: [], }; +/* eslint-disable opencut/prefer-object-params -- React reducers must accept (state, action). */ function processingReducer( state: ProcessingState, action: ProcessingAction, @@ -80,6 +81,7 @@ function processingReducer( return { status: "idle", error: action.error, warnings: [] }; } } +/* eslint-enable opencut/prefer-object-params */ export function Captions() { const [selectedLanguage, setSelectedLanguage] = diff --git a/apps/web/src/subtitles/insert.ts b/apps/web/src/subtitles/insert.ts index 50d6c40b..2a71e181 100644 --- a/apps/web/src/subtitles/insert.ts +++ b/apps/web/src/subtitles/insert.ts @@ -18,7 +18,7 @@ export function insertCaptionChunksAsTextTrack({ return null; } - const addTrackCommand = new AddTrackCommand("text", 0); + const addTrackCommand = new AddTrackCommand({ type: "text", index: 0 }); const trackId = addTrackCommand.getTrackId(); const canvasSize = editor.project.getActive().settings.canvasSize; const insertCommands = captions.map( diff --git a/apps/web/src/subtitles/types.ts b/apps/web/src/subtitles/types.ts index e4f5db3c..ec485f22 100644 --- a/apps/web/src/subtitles/types.ts +++ b/apps/web/src/subtitles/types.ts @@ -1,4 +1,10 @@ -import type { TextBackground, TextElement } from "@/timeline"; +import type { TextBackground } from "@/text/background"; +import type { + TextAlign, + TextDecoration, + TextFontStyle, + TextFontWeight, +} from "@/text/primitives"; import type { CaptionChunk } from "@/transcription/types"; export interface SubtitlePlacementStyle { @@ -26,10 +32,10 @@ export interface SubtitleStyleOverrides { color?: string; background?: Pick<TextBackground, "enabled" | "color"> & Partial<Omit<TextBackground, "enabled" | "color">>; - textAlign?: TextElement["textAlign"]; - fontWeight?: TextElement["fontWeight"]; - fontStyle?: TextElement["fontStyle"]; - textDecoration?: TextElement["textDecoration"]; + textAlign?: TextAlign; + fontWeight?: TextFontWeight; + fontStyle?: TextFontStyle; + textDecoration?: TextDecoration; letterSpacing?: number; lineHeight?: number; placement?: SubtitlePlacementStyle; diff --git a/apps/web/src/text/background.ts b/apps/web/src/text/background.ts index be5ab6f5..baa67a52 100644 --- a/apps/web/src/text/background.ts +++ b/apps/web/src/text/background.ts @@ -1,2 +1,12 @@ export const CORNER_RADIUS_MIN = 0; export const CORNER_RADIUS_MAX = 100; + +export interface TextBackground { + enabled: boolean; + color: string; + cornerRadius?: number; + paddingX?: number; + paddingY?: number; + offsetX?: number; + offsetY?: number; +} diff --git a/apps/web/src/text/components/assets-view.tsx b/apps/web/src/text/components/assets-view.tsx index 25a49893..036bc898 100644 --- a/apps/web/src/text/components/assets-view.tsx +++ b/apps/web/src/text/components/assets-view.tsx @@ -1,46 +1,47 @@ -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
-import { useEditor } from "@/editor/use-editor";
-import { DEFAULTS } from "@/timeline/defaults";
-import { buildTextElement } from "@/timeline/element-utils";
-
-export function TextView() {
- const editor = useEditor();
-
- const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
- const activeScene = editor.scenes.getActiveScene();
- if (!activeScene) return;
-
- const element = buildTextElement({
- raw: DEFAULTS.text.element,
- startTime: currentTime,
- });
-
- editor.timeline.insertElement({
- element,
- placement: { mode: "auto" },
- });
- };
-
- return (
- <PanelView title="Text">
- <DraggableItem
- name="Default text"
- preview={
- <div className="bg-accent flex size-full items-center justify-center rounded">
- <span className="text-xs select-none">Default text</span>
- </div>
- }
- dragData={{
- id: "temp-text-id",
- type: DEFAULTS.text.element.type,
- name: DEFAULTS.text.element.name,
- content: DEFAULTS.text.element.content,
- }}
- aspectRatio={1}
- onAddToTimeline={handleAddToTimeline}
- shouldShowLabel={false}
- />
- </PanelView>
- );
-}
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; +import { useEditor } from "@/editor/use-editor"; +import { DEFAULTS } from "@/timeline/defaults"; +import { buildTextElement } from "@/timeline/element-utils"; +import type { MediaTime } from "@/wasm"; + +export function TextView() { + const editor = useEditor(); + + const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => { + const activeScene = editor.scenes.getActiveScene(); + if (!activeScene) return; + + const element = buildTextElement({ + raw: DEFAULTS.text.element, + startTime: currentTime, + }); + + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + }; + + return ( + <PanelView title="Text"> + <DraggableItem + name="Default text" + preview={ + <div className="bg-accent flex size-full items-center justify-center rounded"> + <span className="text-xs select-none">Default text</span> + </div> + } + dragData={{ + id: "temp-text-id", + type: DEFAULTS.text.element.type, + name: DEFAULTS.text.element.name, + content: "Default text", + }} + aspectRatio={1} + onAddToTimeline={handleAddToTimeline} + shouldShowLabel={false} + /> + </PanelView> + ); +} diff --git a/apps/web/src/text/components/text-tab.tsx b/apps/web/src/text/components/text-tab.tsx deleted file mode 100644 index 0f0b274a..00000000 --- a/apps/web/src/text/components/text-tab.tsx +++ /dev/null @@ -1,767 +0,0 @@ -"use client"; - -import { Textarea } from "@/components/ui/textarea"; -import { FontPicker } from "@/components/ui/font-picker"; -import type { TextElement } from "@/timeline"; -import { NumberField } from "@/components/ui/number-field"; -import { useRef } from "react"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { Button } from "@/components/ui/button"; -import { uppercase } from "@/utils/string"; -import { clamp, formatNumberForDisplay } from "@/utils/math"; -import { useEditor } from "@/editor/use-editor"; -import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "@/text/background"; -import { - DEFAULT_TEXT_COLOR, - MAX_FONT_SIZE, - MIN_FONT_SIZE, -} from "@/text/typography"; -import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; -import { useKeyframedColorProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-color-property"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { isPropertyAtDefault } from "@/rendering/components/transform-tab"; -import { - resolveColorAtTime, - resolveNumberAtTime, -} from "@/animation/values"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - MinusSignIcon, - PlusSignIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; -import { OcTextHeightIcon, OcTextWidthIcon } from "@/components/icons"; -import { DEFAULTS } from "@/timeline/defaults"; -import { cn } from "@/utils/ui"; - -export function TextTab({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - return ( - <div className="flex flex-col"> - <ContentSection element={element} trackId={trackId} /> - <TypographySection element={element} trackId={trackId} /> - <SpacingSection element={element} trackId={trackId} /> - <BackgroundSection element={element} trackId={trackId} /> - </div> - ); -} - -function ContentSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const content = usePropertyDraft({ - displayValue: element.content, - parse: (input) => input, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { content: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:content`}> - <SectionHeader> - <SectionTitle>Content</SectionTitle> - </SectionHeader> - <SectionContent> - <Textarea - placeholder="Name" - value={content.displayValue} - className="min-h-20" - onFocus={content.onFocus} - onChange={content.onChange} - onBlur={content.onBlur} - /> - </SectionContent> - </Section> - ); -} - -function TypographySection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTextColor = resolveColorAtTime({ - baseColor: element.color, - animations: element.animations, - propertyPath: "color", - localTime, - }); - - const textColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedTextColor, - buildBaseUpdates: ({ value }) => ({ color: value }), - }); - - const fontSize = usePropertyDraft({ - displayValue: element.fontSize.toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: MIN_FONT_SIZE, - max: MAX_FONT_SIZE, - }); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { fontSize: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:typography`}> - <SectionHeader> - <SectionTitle>Typography</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField label="Font"> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { fontFamily: value }, - }, - ], - }) - } - /> - </SectionField> - <SectionField label="Size"> - <NumberField - value={fontSize.displayValue} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={fontSize.onFocus} - onChange={fontSize.onChange} - onBlur={fontSize.onBlur} - onScrub={fontSize.scrubTo} - onScrubEnd={fontSize.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - fontSize: DEFAULTS.text.element.fontSize, - }, - }, - ], - }) - } - isDefault={element.fontSize === DEFAULTS.text.element.fontSize} - icon={<HugeiconsIcon icon={TextFontIcon} />} - /> - </SectionField> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={textColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle text color keyframe" - onToggle={textColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={uppercase({ - string: resolvedTextColor.replace("#", ""), - })} - onChange={(color) => textColor.onChange({ color: `#${color}` })} - onChangeEnd={textColor.onChangeEnd} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} - -function SpacingSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const letterSpacing = usePropertyDraft({ - displayValue: Math.round( - element.letterSpacing ?? DEFAULTS.text.letterSpacing, - ).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { letterSpacing: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const lineHeight = usePropertyDraft({ - displayValue: formatNumberForDisplay({ - value: element.lineHeight ?? DEFAULTS.text.lineHeight, - fractionDigits: 1, - }), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) - ? null - : Math.max(0.1, Math.round(parsed * 10) / 10); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { lineHeight: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:spacing`}> - <SectionHeader> - <SectionTitle>Spacing</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField label="Letter spacing" className="w-1/2"> - <NumberField - value={letterSpacing.displayValue} - onFocus={letterSpacing.onFocus} - onChange={letterSpacing.onChange} - onBlur={letterSpacing.onBlur} - onScrub={letterSpacing.scrubTo} - onScrubEnd={letterSpacing.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { letterSpacing: DEFAULTS.text.letterSpacing }, - }, - ], - }) - } - isDefault={ - (element.letterSpacing ?? DEFAULTS.text.letterSpacing) === - DEFAULTS.text.letterSpacing - } - icon={<OcTextWidthIcon size={14} />} - /> - </SectionField> - <SectionField label="Line height" className="w-1/2"> - <NumberField - value={lineHeight.displayValue} - onFocus={lineHeight.onFocus} - onChange={lineHeight.onChange} - onBlur={lineHeight.onBlur} - onScrub={lineHeight.scrubTo} - onScrubEnd={lineHeight.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { lineHeight: DEFAULTS.text.lineHeight }, - }, - ], - }) - } - isDefault={ - (element.lineHeight ?? DEFAULTS.text.lineHeight) === - DEFAULTS.text.lineHeight - } - icon={<OcTextHeightIcon size={14} />} - /> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} - -function BackgroundSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedBgColor = resolveColorAtTime({ - baseColor: element.background.color, - animations: element.animations, - propertyPath: "background.color", - localTime, - }); - - const bgColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedBgColor, - buildBaseUpdates: ({ value }) => ({ - background: { ...element.background, color: value }, - }), - }); - - const bg = element.background; - - const resolvedPaddingX = resolveNumberAtTime({ - baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - }); - const resolvedPaddingY = resolveNumberAtTime({ - baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - }); - const resolvedOffsetX = resolveNumberAtTime({ - baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - }); - const resolvedOffsetY = resolveNumberAtTime({ - baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - }); - const resolvedCornerRadius = resolveNumberAtTime({ - baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - }); - - const paddingX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingX: value }, - }), - }); - - const paddingY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingY: value }, - }), - }); - - const offsetX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetX: value }, - }), - }); - - const offsetY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetY: value }, - }), - }); - - const cornerRadius = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedCornerRadius).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - }); - }, - valueAtPlayhead: resolvedCornerRadius, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, cornerRadius: value }, - }), - }); - - const toggleBackgroundEnabled = () => { - const enabled = !element.background.enabled; - const color = - enabled && element.background.color === "transparent" - ? lastSelectedColor.current - : element.background.color; - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - patch: { - background: { - ...element.background, - enabled, - color, - }, - }, - }, - ], - }); - }; - - return ( - <Section - collapsible - defaultOpen={element.background.enabled} - sectionKey={`${element.id}:background`} - > - <SectionHeader - trailing={ - <Button - variant="ghost" - size="icon" - onClick={(event) => { - event.stopPropagation(); - toggleBackgroundEnabled(); - }} - > - <HugeiconsIcon - icon={element.background.enabled ? MinusSignIcon : PlusSignIcon} - strokeWidth={1} - /> - </Button> - } - > - <SectionTitle>Background</SectionTitle> - </SectionHeader> - <SectionContent - className={cn( - !element.background.enabled && "pointer-events-none opacity-50", - )} - > - <SectionFields> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={bgColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background color keyframe" - onToggle={bgColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={ - !element.background.enabled || - element.background.color === "transparent" - ? lastSelectedColor.current.replace("#", "") - : resolvedBgColor.replace("#", "") - } - onChange={(color) => { - const hexColor = `#${color}`; - if (color !== "transparent") { - lastSelectedColor.current = hexColor; - } - bgColor.onChange({ color: hexColor }); - }} - onChangeEnd={bgColor.onChangeEnd} - /> - </SectionField> - <div className="flex items-start gap-2"> - <SectionField - label="Width" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background width keyframe" - onToggle={paddingX.toggleKeyframe} - /> - } - > - <NumberField - icon="W" - value={paddingX.displayValue} - min={0} - onFocus={paddingX.onFocus} - onChange={paddingX.onChange} - onBlur={paddingX.onBlur} - onScrub={paddingX.scrubTo} - onScrubEnd={paddingX.commitScrub} - onReset={() => - paddingX.commitValue({ - value: DEFAULTS.text.background.paddingX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingX, - staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - defaultValue: DEFAULTS.text.background.paddingX, - })} - /> - </SectionField> - <SectionField - label="Height" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background height keyframe" - onToggle={paddingY.toggleKeyframe} - /> - } - > - <NumberField - icon="H" - value={paddingY.displayValue} - min={0} - onFocus={paddingY.onFocus} - onChange={paddingY.onChange} - onBlur={paddingY.onBlur} - onScrub={paddingY.scrubTo} - onScrubEnd={paddingY.commitScrub} - onReset={() => - paddingY.commitValue({ - value: DEFAULTS.text.background.paddingY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingY, - staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - defaultValue: DEFAULTS.text.background.paddingY, - })} - /> - </SectionField> - </div> - <div className="flex items-start gap-2"> - <SectionField - label="X-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle x-offset keyframe" - onToggle={offsetX.toggleKeyframe} - /> - } - > - <NumberField - icon="X" - value={offsetX.displayValue} - onFocus={offsetX.onFocus} - onChange={offsetX.onChange} - onBlur={offsetX.onBlur} - onScrub={offsetX.scrubTo} - onScrubEnd={offsetX.commitScrub} - onReset={() => - offsetX.commitValue({ - value: DEFAULTS.text.background.offsetX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetX, - staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - defaultValue: DEFAULTS.text.background.offsetX, - })} - /> - </SectionField> - <SectionField - label="Y-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle y-offset keyframe" - onToggle={offsetY.toggleKeyframe} - /> - } - > - <NumberField - icon="Y" - value={offsetY.displayValue} - onFocus={offsetY.onFocus} - onChange={offsetY.onChange} - onBlur={offsetY.onBlur} - onScrub={offsetY.scrubTo} - onScrubEnd={offsetY.commitScrub} - onReset={() => - offsetY.commitValue({ - value: DEFAULTS.text.background.offsetY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetY, - staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - defaultValue: DEFAULTS.text.background.offsetY, - })} - /> - </SectionField> - </div> - <SectionField - label="Corner radius" - beforeLabel={ - <KeyframeToggle - isActive={cornerRadius.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle corner radius keyframe" - onToggle={cornerRadius.toggleKeyframe} - /> - } - > - <NumberField - icon="R" - value={cornerRadius.displayValue} - min={CORNER_RADIUS_MIN} - max={CORNER_RADIUS_MAX} - onFocus={cornerRadius.onFocus} - onChange={cornerRadius.onChange} - onBlur={cornerRadius.onBlur} - onScrub={cornerRadius.scrubTo} - onScrubEnd={cornerRadius.commitScrub} - onReset={() => - cornerRadius.commitValue({ value: CORNER_RADIUS_MIN }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedCornerRadius, - staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - defaultValue: CORNER_RADIUS_MIN, - })} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} diff --git a/apps/web/src/text/layout.ts b/apps/web/src/text/layout.ts index aabf5ca9..c52abc30 100644 --- a/apps/web/src/text/layout.ts +++ b/apps/web/src/text/layout.ts @@ -1,5 +1,6 @@ -import type { TextBackground, TextElement } from "@/timeline"; +import type { TextBackground } from "@/text/background"; import { DEFAULTS } from "@/timeline/defaults"; +import type { TextAlign } from "@/text/primitives"; type TextRect = { left: number; @@ -78,7 +79,7 @@ function getTextRect({ textAlign, block, }: { - textAlign: TextElement["textAlign"]; + textAlign: TextAlign; block: TextBlockMeasurement; }): TextRect { const textAlignToLeft: Record<typeof textAlign, number> = { @@ -114,7 +115,7 @@ export function getTextBackgroundRect({ background, fontSizeRatio = 1, }: { - textAlign: TextElement["textAlign"]; + textAlign: TextAlign; block: TextBlockMeasurement; background: TextBackground; fontSizeRatio?: number; @@ -145,7 +146,7 @@ export function getTextVisualRect({ background, fontSizeRatio = 1, }: { - textAlign: TextElement["textAlign"]; + textAlign: TextAlign; block: TextBlockMeasurement; background: TextBackground; fontSizeRatio?: number; diff --git a/apps/web/src/text/measure-element.ts b/apps/web/src/text/measure-element.ts index ec14421b..28e97eac 100644 --- a/apps/web/src/text/measure-element.ts +++ b/apps/web/src/text/measure-element.ts @@ -1,6 +1,7 @@ import { CORNER_RADIUS_MIN } from "@/text/background"; import { DEFAULTS } from "@/timeline/defaults"; -import type { TextBackground, TextElement } from "@/timeline"; +import type { TextElement } from "@/timeline"; +import type { TextBackground } from "@/text/background"; import { resolveNumberAtTime } from "@/animation/values"; import { getTextVisualRect, @@ -8,6 +9,11 @@ import { import { measureTextLayout, type MeasuredTextLayout, + type TextAlign, + type TextDecoration, + type TextFontStyle, + type TextFontWeight, + type TextLayoutParams, } from "./primitives"; export interface ResolvedTextBackground extends TextBackground { @@ -67,23 +73,14 @@ export function measureTextElement({ localTime: number; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; }): MeasuredTextElement { + const text = buildTextLayoutParamsFromElement({ element }); const measuredLayout = measureTextLayout({ - text: { - content: element.content, - fontSize: element.fontSize, - fontFamily: element.fontFamily, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textAlign: element.textAlign, - textDecoration: element.textDecoration, - letterSpacing: element.letterSpacing, - lineHeight: element.lineHeight, - }, + text, canvasHeight, ctx, }); - const bg = element.background; + const bg = buildTextBackgroundFromElement({ element }); const resolvedBackground: ResolvedTextBackground = { ...bg, paddingX: resolveNumberAtTime({ @@ -119,7 +116,7 @@ export function measureTextElement({ }; const visualRect = getTextVisualRect({ - textAlign: element.textAlign, + textAlign: text.textAlign, block: measuredLayout.block, background: resolvedBackground, fontSizeRatio: measuredLayout.fontSizeRatio, @@ -131,3 +128,180 @@ export function measureTextElement({ visualRect, }; } + +export function buildTextLayoutParamsFromElement({ + element, +}: { + element: TextElement; +}): TextLayoutParams { + return { + content: readStringParam({ + params: element.params, + key: "content", + fallback: "Default text", + }), + fontSize: readNumberParam({ + params: element.params, + key: "fontSize", + fallback: 15, + }), + fontFamily: readStringParam({ + params: element.params, + key: "fontFamily", + fallback: "Arial", + }), + fontWeight: readFontWeight({ + value: element.params.fontWeight, + fallback: "normal", + }), + fontStyle: readFontStyle({ + value: element.params.fontStyle, + fallback: "normal", + }), + textAlign: readTextAlign({ + value: element.params.textAlign, + fallback: "center", + }), + textDecoration: readTextDecoration({ + value: element.params.textDecoration, + fallback: "none", + }), + letterSpacing: readNumberParam({ + params: element.params, + key: "letterSpacing", + fallback: DEFAULTS.text.letterSpacing, + }), + lineHeight: readNumberParam({ + params: element.params, + key: "lineHeight", + fallback: DEFAULTS.text.lineHeight, + }), + }; +} + +export function buildTextBackgroundFromElement({ + element, +}: { + element: TextElement; +}): TextBackground { + return { + enabled: readBooleanParam({ + params: element.params, + key: "background.enabled", + fallback: DEFAULTS.text.background.enabled, + }), + color: readStringParam({ + params: element.params, + key: "background.color", + fallback: DEFAULTS.text.background.color, + }), + cornerRadius: readNumberParam({ + params: element.params, + key: "background.cornerRadius", + fallback: DEFAULTS.text.background.cornerRadius, + }), + paddingX: readNumberParam({ + params: element.params, + key: "background.paddingX", + fallback: DEFAULTS.text.background.paddingX, + }), + paddingY: readNumberParam({ + params: element.params, + key: "background.paddingY", + fallback: DEFAULTS.text.background.paddingY, + }), + offsetX: readNumberParam({ + params: element.params, + key: "background.offsetX", + fallback: DEFAULTS.text.background.offsetX, + }), + offsetY: readNumberParam({ + params: element.params, + key: "background.offsetY", + fallback: DEFAULTS.text.background.offsetY, + }), + }; +} + +function readStringParam({ + params, + key, + fallback, +}: { + params: TextElement["params"]; + key: string; + fallback: string; +}): string { + const value = params[key]; + return typeof value === "string" ? value : fallback; +} + +function readNumberParam({ + params, + key, + fallback, +}: { + params: TextElement["params"]; + key: string; + fallback: number; +}): number { + const value = params[key]; + return typeof value === "number" ? value : fallback; +} + +function readBooleanParam({ + params, + key, + fallback, +}: { + params: TextElement["params"]; + key: string; + fallback: boolean; +}): boolean { + const value = params[key]; + return typeof value === "boolean" ? value : fallback; +} + +function readTextAlign({ + value, + fallback, +}: { + value: unknown; + fallback: TextAlign; +}): TextAlign { + return value === "left" || value === "center" || value === "right" + ? value + : fallback; +} + +function readFontWeight({ + value, + fallback, +}: { + value: unknown; + fallback: TextFontWeight; +}): TextFontWeight { + return value === "bold" || value === "normal" ? value : fallback; +} + +function readFontStyle({ + value, + fallback, +}: { + value: unknown; + fallback: TextFontStyle; +}): TextFontStyle { + return value === "italic" || value === "normal" ? value : fallback; +} + +function readTextDecoration({ + value, + fallback, +}: { + value: unknown; + fallback: TextDecoration; +}): TextDecoration { + return value === "none" || value === "underline" || value === "line-through" + ? value + : fallback; +} diff --git a/apps/web/src/text/primitives.ts b/apps/web/src/text/primitives.ts index 9a9262a4..b2eefa95 100644 --- a/apps/web/src/text/primitives.ts +++ b/apps/web/src/text/primitives.ts @@ -85,7 +85,7 @@ export function resolveTextLayout({ const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing; const lineHeightPx = scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight); - const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize; + const fontSizeRatio = text.fontSize / 15; return { scaledFontSize, diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts new file mode 100644 index 00000000..40a0e645 --- /dev/null +++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import type { Transform } from "@/rendering"; +import type { SceneTracks, VideoElement } from "@/timeline"; +import { applyElementUpdate } from "@/timeline/update-pipeline"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; + +function buildTransform(): Transform { + return { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; +} + +function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement { + return { + id: "video-1", + type: "video", + name: "Video 1", + startTime: ZERO_MEDIA_TIME, + duration: mediaTime({ ticks: 10 }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + mediaId: "media-1", + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, + ...overrides, + }; +} + +function buildTracks(element: VideoElement): SceneTracks { + return { + overlay: [], + main: { + id: "main-track", + type: "video", + name: "Main", + muted: false, + hidden: false, + elements: [element], + }, + audio: [], + }; +} + +describe("applyElementUpdate", () => { + test("rounds retimed durations back to integer media time", () => { + const element = buildVideoElement(); + const tracks = buildTracks(element); + + const updatedElement = applyElementUpdate({ + element, + patch: { + retime: { rate: 1.5 }, + }, + context: { + tracks, + trackId: tracks.main.id, + }, + }); + + expect(updatedElement.duration).toBe(7); + expect(Number.isInteger(updatedElement.duration)).toBe(true); + }); +}); diff --git a/apps/web/src/timeline/animation-properties.ts b/apps/web/src/timeline/animation-properties.ts deleted file mode 100644 index 31479a58..00000000 --- a/apps/web/src/timeline/animation-properties.ts +++ /dev/null @@ -1,425 +0,0 @@ -import type { - AnimationBindingKind, - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, - ElementAnimations, - NumericSpec, -} from "@/animation/types"; -import { upsertPathKeyframe } from "@/animation"; -import { parseColorToLinearRgba } from "@/animation/binding-values"; -import { isAnimationPropertyPath } from "@/animation/path"; -import { MIN_TRANSFORM_SCALE } from "@/animation/transform"; -import { snapToStep } from "@/utils/math"; -import type { TimelineElement } from "@/timeline"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/text/background"; -import { - canElementHaveAudio, - isVisualElement, -} from "@/timeline/element-utils"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { DEFAULTS } from "@/timeline/defaults"; - -export interface AnimationPropertyDefinition { - kind: AnimationBindingKind; - defaultInterpolation: AnimationInterpolation; - numericRanges?: Partial<Record<string, NumericSpec>>; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceNumberValue({ - value, - numericRange, -}: { - value: AnimationValue; - numericRange?: NumericSpec; -}): number | null { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ value, numericRange }); -} - -function coerceColorValue({ - value, -}: { - value: AnimationValue; -}): string | null { - return typeof value === "string" && parseColorToLinearRgba({ color: value }) - ? value - : null; -} - -function createNumberPropertyDefinition({ - numericRange, - supportsElement, - getValue, - setValue, -}: { - numericRange?: NumericSpec; - supportsElement: AnimationPropertyDefinition["supportsElement"]; - getValue: AnimationPropertyDefinition["getValue"]; - setValue: AnimationPropertyDefinition["setValue"]; -}): AnimationPropertyDefinition { - return { - kind: "number", - defaultInterpolation: "linear", - numericRanges: numericRange ? { value: numericRange } : undefined, - supportsElement, - getValue, - coerceValue: ({ value }) => - coerceNumberValue({ - value, - numericRange, - }), - setValue, - }; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.positionX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.x : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, x: value as number }, - }, - } - : element, - }), - "transform.positionY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position.y : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: { ...element.transform.position, y: value as number }, - }, - } - : element, - }), - "transform.scaleX": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }), - "transform.scaleY": createNumberPropertyDefinition({ - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }), - "transform.rotate": createNumberPropertyDefinition({ - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }), - opacity: createNumberPropertyDefinition({ - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }), - volume: createNumberPropertyDefinition({ - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }), - color: { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - kind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - coerceValue: ({ value }) => coerceColorValue({ value }), - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }), - "background.paddingY": createNumberPropertyDefinition({ - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }), - "background.offsetX": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }), - "background.offsetY": createNumberPropertyDefinition({ - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }), - "background.cornerRadius": createNumberPropertyDefinition({ - numericRange: { - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - step: 1, - }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }), -}; - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const definition = getAnimationPropertyDefinition({ propertyPath }); - const coercedValue = definition.coerceValue({ value }); - if (coercedValue === null || !definition.supportsElement({ element })) { - return element; - } - - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.coerceValue({ value }); -} - -export function upsertElementKeyframe({ - animations, - propertyPath, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; -}): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath, - value, - }); - if (coercedValue === null) { - return animations; - } - - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return upsertPathKeyframe({ - animations, - propertyPath, - time, - value: coercedValue, - interpolation, - keyframeId, - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - coerceValue: ({ value: nextValue }) => - coerceAnimationValueForProperty({ - propertyPath, - value: nextValue, - }), - }); -} diff --git a/apps/web/src/timeline/animation-snap-points.ts b/apps/web/src/timeline/animation-snap-points.ts index f7bf549a..e64de11f 100644 --- a/apps/web/src/timeline/animation-snap-points.ts +++ b/apps/web/src/timeline/animation-snap-points.ts @@ -1,6 +1,7 @@ import { getElementKeyframes } from "@/animation"; import type { SceneTracks } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import { addMediaTime } from "@/wasm"; export function getAnimationKeyframeSnapPointsForTimeline({ tracks, @@ -22,7 +23,7 @@ export function getAnimationKeyframeSnapPointsForTimeline({ animations: element.animations, })) { snapPoints.push({ - time: element.startTime + keyframe.time, + time: addMediaTime({ a: element.startTime, b: keyframe.time }), type: "keyframe", elementId: element.id, trackId: track.id, diff --git a/apps/web/src/timeline/animation-targets.ts b/apps/web/src/timeline/animation-targets.ts index f9d53fcc..4ee764b6 100644 --- a/apps/web/src/timeline/animation-targets.ts +++ b/apps/web/src/timeline/animation-targets.ts @@ -1,17 +1,9 @@ import type { - AnimationBindingKind, AnimationInterpolation, AnimationPath, - AnimationValue, NumericSpec, } from "@/animation/types"; import { - coerceAnimationParamValue, - getAnimationParamDefaultInterpolation, - getAnimationParamNumericRange, - getAnimationParamValueKind, -} from "@/animation/animated-params"; -import { parseEffectParamPath, } from "@/animation/effect-param-channel"; import { @@ -19,38 +11,97 @@ import { } from "@/animation/graphic-param-channel"; import { effectsRegistry, registerDefaultEffects } from "@/effects"; import { getGraphicDefinition } from "@/graphics"; -import type { ParamDefinition } from "@/params"; +import { + coerceParamValue, + getParamDefaultInterpolation, + getParamChannelLayout, + getParamNumericRange, + type ParamChannelLayout, + type ParamDefinition, + type ParamValue, + type ParamValues, +} from "@/params"; +import { + getElementParam, +} from "@/params/registry"; import type { TimelineElement } from "@/timeline"; import { isVisualElement } from "@/timeline/element-utils"; -import { isAnimationPropertyPath } from "@/animation/path"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getElementBaseValueForProperty, - withElementBaseValueForProperty, -} from "./animation-properties"; export interface AnimationPathDescriptor { - kind: AnimationBindingKind; + channelLayout: ParamChannelLayout; defaultInterpolation: AnimationInterpolation; numericRanges?: Partial<Record<string, NumericSpec>>; - coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; - getBaseValue: () => AnimationValue | null; - setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; + coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null; + getBaseValue: () => ParamValue | null; + setBaseValue: ({ value }: { value: ParamValue }) => TimelineElement; } -// Number/discrete bindings expose a single component named "value" -// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry -// numeric ranges yet — revisit when one does. +// Leaf params expose a single component named "value". Composite params don't +// carry numeric ranges yet — revisit when one does. function paramNumericRanges({ param, }: { param: ParamDefinition; }): Partial<Record<string, NumericSpec>> | undefined { - const range = getAnimationParamNumericRange({ param }); + const range = getParamNumericRange({ param }); return range ? { value: range } : undefined; } +function buildParamDescriptor({ + param, + baseParams, + setParams, +}: { + param: ParamDefinition; + baseParams: ParamValues; + setParams: (params: ParamValues) => TimelineElement; +}): AnimationPathDescriptor | null { + if (param.keyframable === false) { + return null; + } + + return { + channelLayout: getParamChannelLayout({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: paramNumericRanges({ param }), + coerceValue: ({ value }) => coerceParamValue({ param, value }), + getBaseValue: () => baseParams[param.key] ?? param.default, + setBaseValue: ({ value }) => { + const coercedValue = coerceParamValue({ param, value }); + if (coercedValue === null) { + return setParams(baseParams); + } + + return setParams({ + ...baseParams, + [param.key]: coercedValue, + }); + }, + }; +} + +function buildElementParamDescriptor({ + element, + paramKey, +}: { + element: TimelineElement; + paramKey: string; +}): AnimationPathDescriptor | null { + const param = getElementParam({ element, key: paramKey }); + if (!param) { + return null; + } + + return buildParamDescriptor({ + param, + baseParams: element.params, + setParams: (params) => ({ + ...element, + params, + }), + }); +} + function buildGraphicParamDescriptor({ element, paramKey, @@ -70,27 +121,14 @@ function buildGraphicParamDescriptor({ return null; } - return { - kind: getAnimationParamValueKind({ param }), - defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), - numericRanges: paramNumericRanges({ param }), - coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), - getBaseValue: () => element.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - params: { - ...element.params, - [param.key]: coercedValue, - }, - }; - }, - }; + return buildParamDescriptor({ + param, + baseParams: element.params, + setParams: (params) => ({ + ...element, + params, + }), + }); } function buildEffectParamDescriptor({ @@ -118,35 +156,22 @@ function buildEffectParamDescriptor({ return null; } - return { - kind: getAnimationParamValueKind({ param }), - defaultInterpolation: getAnimationParamDefaultInterpolation({ param }), - numericRanges: paramNumericRanges({ param }), - coerceValue: ({ value }) => coerceAnimationParamValue({ param, value }), - getBaseValue: () => effect.params[param.key] ?? param.default, - setBaseValue: ({ value }) => { - const coercedValue = coerceAnimationParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - effects: - element.effects?.map((candidate) => - candidate.id !== effectId - ? candidate - : { - ...candidate, - params: { - ...candidate.params, - [param.key]: coercedValue, - }, - }, - ) ?? element.effects, - }; - }, - }; + return buildParamDescriptor({ + param, + baseParams: effect.params, + setParams: (params) => ({ + ...element, + effects: + element.effects?.map((candidate) => + candidate.id !== effectId + ? candidate + : { + ...candidate, + params, + }, + ) ?? element.effects, + }), + }); } export function resolveAnimationTarget({ @@ -156,41 +181,12 @@ export function resolveAnimationTarget({ element: TimelineElement; path: AnimationPath; }): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(path)) { - const propertyDefinition = getAnimationPropertyDefinition({ - propertyPath: path, - }); - if (!propertyDefinition.supportsElement({ element })) { - return null; - } - - return { - kind: propertyDefinition.kind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - numericRanges: propertyDefinition.numericRanges, - coerceValue: ({ value }) => - coerceAnimationValueForProperty({ - propertyPath: path, - value, - }), - getBaseValue: () => - getElementBaseValueForProperty({ - element, - propertyPath: path, - }), - setBaseValue: ({ value }) => { - const coercedValue = propertyDefinition.coerceValue({ value }); - if (coercedValue === null) { - return element; - } - - return withElementBaseValueForProperty({ - element, - propertyPath: path, - value: coercedValue, - }); - }, - }; + const elementParamTarget = buildElementParamDescriptor({ + element, + paramKey: path, + }); + if (elementParamTarget) { + return elementParamTarget; } const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); diff --git a/apps/web/src/timeline/audio-separation/index.ts b/apps/web/src/timeline/audio-separation/index.ts index beb488de..6917378c 100644 --- a/apps/web/src/timeline/audio-separation/index.ts +++ b/apps/web/src/timeline/audio-separation/index.ts @@ -87,8 +87,13 @@ export function buildSeparatedAudioElement({ trimStart: sourceElement.trimStart, trimEnd: sourceElement.trimEnd, sourceDuration: sourceElement.sourceDuration, - volume: sourceElement.volume ?? DEFAULTS.element.volume, - muted: sourceElement.muted ?? false, + params: { + volume: + typeof sourceElement.params.volume === "number" + ? sourceElement.params.volume + : DEFAULTS.element.volume, + muted: sourceElement.params.muted === true, + }, retime: sourceElement.retime ? { rate: sourceElement.retime.rate, @@ -116,28 +121,13 @@ function cloneVolumeAnimations({ }: { animations: ElementAnimations | undefined; }): ElementAnimations | undefined { - const volumeBinding = animations?.bindings.volume; - if (!volumeBinding) { - return undefined; - } - - const subsetChannels = Object.fromEntries( - volumeBinding.components.flatMap((component) => { - const channel = animations?.channels[component.channelId]; - return channel ? [[component.channelId, channel] as const] : []; - }), - ); - if (Object.keys(subsetChannels).length === 0) { + const volumeData = animations?.volume; + if (!volumeData) { return undefined; } return cloneAnimations({ - animations: { - bindings: { - volume: volumeBinding, - }, - channels: subsetChannels, - }, + animations: { volume: volumeData }, shouldRegenerateKeyframeIds: true, }); } diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts index 44df5aaf..6c8f3044 100644 --- a/apps/web/src/timeline/audio-state.ts +++ b/apps/web/src/timeline/audio-state.ts @@ -21,6 +21,23 @@ export function dBToLinear(db: number): number { return 10 ** (clampDb(db) / 20); } +export function getElementVolume({ + element, +}: { + element: AudioCapableElement; +}): number { + const value = element.params.volume; + return typeof value === "number" ? value : 0; +} + +export function isElementMuted({ + element, +}: { + element: AudioCapableElement; +}): boolean { + return element.params.muted === true; +} + export function hasAnimatedVolume({ element, }: { @@ -43,12 +60,12 @@ export function resolveEffectiveAudioGain({ trackMuted?: boolean; localTime: number; }): number { - if (trackMuted || element.muted === true) { + if (trackMuted || isElementMuted({ element })) { return 0; } const resolvedDb = resolveNumberAtTime({ - baseValue: element.volume ?? 0, + baseValue: getElementVolume({ element }), animations: element.animations, propertyPath: "volume", localTime: Math.round(localTime * TICKS_PER_SECOND), diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx index 20e22c57..43ba4ffc 100644 --- a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx +++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx @@ -7,7 +7,6 @@ import type { BookmarkDragState } from "../hooks/use-bookmark-drag"; import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme"; import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout"; import { DEFAULT_FPS } from "@/fps/defaults"; -import { snappedSeekTime } from "opencut-wasm"; import { ArrowTurnBackwardIcon, Delete02Icon, @@ -26,6 +25,14 @@ import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp, formatNumberForDisplay } from "@/utils/math"; import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline"; +import { + type MediaTime, + mediaTimeFromSeconds, + mediaTimeToSeconds, + snapSeekMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; @@ -39,12 +46,12 @@ function seekToBookmarkTime({ time, }: { editor: EditorCore; - time: number; + time: MediaTime; }) { const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); const rate = activeProject?.settings.fps ?? DEFAULT_FPS; - const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; + const snappedTime = snapSeekMediaTime({ time, duration, fps: rate }); editor.playback.seek({ time: snappedTime }); } @@ -137,7 +144,7 @@ function TimelineBookmark({ const displayTime = isDragging ? dragState.currentTime : bookmark.time; const time = bookmark.time; - const bookmarkDuration = bookmark.duration ?? 0; + const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME; const durationWidth = bookmarkDuration > 0 ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) @@ -182,7 +189,7 @@ function TimelineBookmark({ left: `${bookmarkLeft}px`, width: `${bookmarkWidth}px`, }} - aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`} + aria-label={`Bookmark at ${formatNumberForDisplay({ value: mediaTimeToSeconds({ time }), fractionDigits: 1 })}s`} type="button" onMouseDown={handleMouseDown} onClick={handleClick} @@ -286,8 +293,8 @@ function BookmarkPopoverContent({ onPopoverClose, }: { bookmark: Bookmark; - time: number; - timelineDuration: number; + time: MediaTime; + timelineDuration: MediaTime; onPopoverClose: () => void; }) { const editor = useEditor(); @@ -314,9 +321,8 @@ function BookmarkPopoverContent({ note, color, duration, - }: Partial<{ note: string; color: string; duration: number }>) => { - const updates: Partial<{ note: string; color: string; duration: number }> = - {}; + }: Partial<Omit<Bookmark, "time">>) => { + const updates: Partial<Omit<Bookmark, "time">> = {}; if (note !== undefined && note !== bookmark.note) updates.note = note; if ( color !== undefined && @@ -330,6 +336,15 @@ function BookmarkPopoverContent({ if (Object.keys(updates).length === 0) return; editor.scenes.updateBookmark({ time, updates }); }; + const maxDuration = mediaTimeToSeconds({ + time: + timelineDuration > time + ? subMediaTime({ a: timelineDuration, b: time }) + : ZERO_MEDIA_TIME, + }); + const durationSeconds = mediaTimeToSeconds({ + time: bookmark.duration ?? ZERO_MEDIA_TIME, + }); return ( <> @@ -387,7 +402,7 @@ function BookmarkPopoverContent({ type="number" min={0} step={0.1} - value={bookmark.duration ?? 0} + value={durationSeconds} onChange={(event) => { const parsed = parseFloat(event.target.value); const value = Number.isNaN(parsed) @@ -395,9 +410,11 @@ function BookmarkPopoverContent({ : clamp({ value: parsed, min: 0, - max: Math.max(0, timelineDuration - time), + max: maxDuration, }); - handleUpdate({ duration: value }); + handleUpdate({ + duration: mediaTimeFromSeconds({ seconds: value }), + }); }} className="h-8 text-sm" containerClassName="w-full" diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts index 35ee9cca..4c03ef8c 100644 --- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -8,7 +8,6 @@ import { import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame } from "opencut-wasm"; import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; import { buildTimelineSnapPoints, @@ -21,15 +20,16 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import type { Bookmark } from "@/timeline"; +import { roundFrameTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export interface BookmarkDragState { isDragging: boolean; - bookmarkTime: number | null; - currentTime: number; + bookmarkTime: MediaTime | null; + currentTime: MediaTime; } interface PendingBookmarkDrag { - bookmarkTime: number; + bookmarkTime: MediaTime; startMouseX: number; startMouseY: number; } @@ -58,7 +58,7 @@ export function useBookmarkDrag({ const [dragState, setDragState] = useState<BookmarkDragState>({ isDragging: false, bookmarkTime: null, - currentTime: 0, + currentTime: ZERO_MEDIA_TIME, }); const [isPendingDrag, setIsPendingDrag] = useState(false); const pendingDragRef = useRef<PendingBookmarkDrag | null>(null); @@ -69,8 +69,8 @@ export function useBookmarkDrag({ bookmarkTime, initialCurrentTime, }: { - bookmarkTime: number; - initialCurrentTime: number; + bookmarkTime: MediaTime; + initialCurrentTime: MediaTime; }) => { setDragState({ isDragging: true, @@ -85,7 +85,7 @@ export function useBookmarkDrag({ setDragState({ isDragging: false, bookmarkTime: null, - currentTime: 0, + currentTime: ZERO_MEDIA_TIME, }); }, []); @@ -94,9 +94,9 @@ export function useBookmarkDrag({ rawTime, excludeBookmarkTime, }: { - rawTime: number; - excludeBookmarkTime: number; - }): { snappedTime: number; snapPoint: SnapPoint | null } => { + rawTime: MediaTime; + excludeBookmarkTime: MediaTime; + }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } => { const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (!shouldSnap) { return { snappedTime: rawTime, snapPoint: null }; @@ -162,11 +162,12 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const frameSnappedTime = - roundToFrame({ - time: Math.max(0, Math.min(mouseTime, duration)), - rate: activeProject.settings.fps, - }) ?? Math.max(0, Math.min(mouseTime, duration)); + const clampedTime = + mouseTime > duration ? duration : mouseTime; + const frameSnappedTime = roundFrameTime({ + time: clampedTime, + fps: activeProject.settings.fps, + }); const { snappedTime: initialTime } = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: bookmarkTime, @@ -193,10 +194,12 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const clampedTime = Math.max(0, Math.min(mouseTime, duration)); - const frameSnappedTime = - roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? - clampedTime; + const clampedTime = + mouseTime > duration ? duration : mouseTime; + const frameSnappedTime = roundFrameTime({ + time: clampedTime, + fps: activeProject.settings.fps, + }); const snapResult = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: dragState.bookmarkTime, @@ -234,10 +237,8 @@ export function useBookmarkDrag({ return; } - const clampedTime = Math.max( - 0, - Math.min(dragState.currentTime, duration), - ); + const clampedTime = + dragState.currentTime > duration ? duration : dragState.currentTime; editor.scenes.moveBookmark({ fromTime: dragState.bookmarkTime, diff --git a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx index 8e2b4bcf..2c254c84 100644 --- a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx +++ b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx @@ -5,6 +5,7 @@ import { type PreviewOverlaySourceResult, } from "@/preview/overlays"; import { getBookmarksActiveAtTime } from "./utils"; +import type { MediaTime } from "@/wasm"; export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { id: "bookmark-notes", @@ -15,7 +16,7 @@ export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { function BookmarkNotesOverlay({ bookmarks, }: { - bookmarks: Array<{ time: number; note: string; color?: string }>; + bookmarks: Array<{ time: MediaTime; note: string; color?: string }>; }) { return ( <div className="flex flex-col gap-1.5" aria-live="polite"> @@ -43,7 +44,7 @@ export function getBookmarkPreviewOverlaySource({ isVisible, }: { bookmarks: Bookmark[]; - time: number; + time: MediaTime; isVisible: boolean; }): PreviewOverlaySourceResult { const bookmarksWithNotes = getBookmarksActiveAtTime({ diff --git a/apps/web/src/timeline/bookmarks/snap-source.ts b/apps/web/src/timeline/bookmarks/snap-source.ts index 32a84baa..64d0c3c3 100644 --- a/apps/web/src/timeline/bookmarks/snap-source.ts +++ b/apps/web/src/timeline/bookmarks/snap-source.ts @@ -1,12 +1,13 @@ import type { Bookmark } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import type { MediaTime } from "@/wasm"; export function getBookmarkSnapPoints({ bookmarks, excludeBookmarkTime, }: { bookmarks: Bookmark[]; - excludeBookmarkTime?: number; + excludeBookmarkTime?: MediaTime; }): SnapPoint[] { return bookmarks.flatMap((bookmark) => { if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) { diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts index d05fe3a9..702e66c3 100644 --- a/apps/web/src/timeline/bookmarks/utils.ts +++ b/apps/web/src/timeline/bookmarks/utils.ts @@ -1,13 +1,13 @@ import type { Bookmark } from "@/timeline"; import type { FrameRate } from "opencut-wasm"; -import { roundToFrame } from "opencut-wasm"; +import { addMediaTime, roundFrameTime, type MediaTime } from "@/wasm"; function bookmarkTimeEqual({ bookmarkTime, frameTime, }: { - bookmarkTime: number; - frameTime: number; + bookmarkTime: MediaTime; + frameTime: MediaTime; }): boolean { return bookmarkTime === frameTime; } @@ -17,7 +17,7 @@ export function findBookmarkIndex({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): number { return bookmarks.findIndex((bookmark) => bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), @@ -29,7 +29,7 @@ export function isBookmarkAtTime({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): boolean { return bookmarks.some((bookmark) => bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), @@ -41,7 +41,7 @@ export function toggleBookmarkInArray({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark[] { const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); @@ -58,7 +58,7 @@ export function removeBookmarkFromArray({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark[] { return bookmarks.filter( (bookmark) => @@ -72,7 +72,7 @@ export function updateBookmarkInArray({ updates, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; updates: Partial<Omit<Bookmark, "time">>; }): Bookmark[] { const index = findBookmarkIndex({ bookmarks, frameTime }); @@ -92,8 +92,8 @@ export function moveBookmarkInArray({ toTime, }: { bookmarks: Bookmark[]; - fromTime: number; - toTime: number; + fromTime: MediaTime; + toTime: MediaTime; }): Bookmark[] { const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); if (index === -1) { @@ -110,10 +110,10 @@ export function getFrameTime({ time, fps, }: { - time: number; + time: MediaTime; fps: FrameRate; -}): number { - return roundToFrame({ time, rate: fps }) ?? time; +}): MediaTime { + return roundFrameTime({ time, fps }); } export function getBookmarkAtTime({ @@ -121,7 +121,7 @@ export function getBookmarkAtTime({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark | null { const index = findBookmarkIndex({ bookmarks, frameTime }); return index === -1 ? null : bookmarks[index]; @@ -132,13 +132,13 @@ export function getBookmarksActiveAtTime({ time, }: { bookmarks: Bookmark[]; - time: number; + time: MediaTime; }): Bookmark[] { return bookmarks.filter((bookmark) => { const start = bookmark.time; const end = bookmark.duration != null && bookmark.duration > 0 - ? start + bookmark.duration + ? addMediaTime({ a: start, b: bookmark.duration }) : start; return time >= start && time <= end; }); diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx deleted file mode 100644 index 1e47dcc8..00000000 --- a/apps/web/src/timeline/components/audio-tab.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { NumberField } from "@/components/ui/number-field"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { isSourceAudioSeparated } from "@/timeline/audio-separation"; -import { DEFAULTS } from "@/timeline/defaults"; -import { - clamp, - formatNumberForDisplay, - getFractionDigitsForStep, - isNearlyEqual, - snapToStep, -} from "@/utils/math"; -import type { AudioElement, VideoElement } from "@/timeline"; -import { resolveNumberAtTime } from "@/animation/values"; -import { useEditor } from "@/editor/use-editor"; -import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; -import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; -import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { VolumeHighIcon } from "@hugeicons/core-free-icons"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; - -const VOLUME_STEP = 0.1; -const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); - -export function AudioTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedVolume = resolveNumberAtTime({ - baseValue: element.volume ?? DEFAULTS.element.volume, - animations: element.animations, - propertyPath: "volume", - localTime, - }); - const volume = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "volume", - localTime, - isPlayheadWithinElementRange, - displayValue: formatNumberForDisplay({ - value: resolvedVolume, - fractionDigits: VOLUME_FRACTION_DIGITS, - }), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) { - return null; - } - - return clamp({ - value: snapToStep({ value: parsed, step: VOLUME_STEP }), - min: VOLUME_DB_MIN, - max: VOLUME_DB_MAX, - }); - }, - valueAtPlayhead: resolvedVolume, - step: VOLUME_STEP, - buildBaseUpdates: ({ value }) => ({ - volume: value, - }), - }); - const isDefault = - volume.hasAnimatedKeyframes && isPlayheadWithinElementRange - ? isNearlyEqual({ - leftValue: resolvedVolume, - rightValue: DEFAULTS.element.volume, - }) - : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; - const isSeparated = - element.type === "video" && isSourceAudioSeparated({ element }); - - return ( - <> - {isSeparated && ( - <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3"> - <p className="text-sm">Audio has been separated.</p> - <Button - className="mt-3" - size="sm" - variant="secondary" - onClick={() => - editor.timeline.toggleSourceAudioSeparation({ - trackId, - elementId: element.id, - }) - } - > - Recover audio - </Button> - </div> - )} - <Section collapsible sectionKey={`${element.id}:audio`}> - <SectionHeader> - <SectionTitle>Audio</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField - label="Volume" - beforeLabel={ - <KeyframeToggle - isActive={volume.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle volume keyframe" - onToggle={volume.toggleKeyframe} - /> - } - > - <NumberField - icon={<HugeiconsIcon icon={VolumeHighIcon} />} - value={volume.displayValue} - onFocus={volume.onFocus} - onChange={volume.onChange} - onBlur={volume.onBlur} - dragSensitivity="slow" - scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} - onScrub={volume.scrubTo} - onScrubEnd={volume.commitScrub} - onReset={() => - volume.commitValue({ - value: DEFAULTS.element.volume, - }) - } - isDefault={isDefault} - suffix="dB" - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - </> - ); -} diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx index 1baac2bd..659bc5dc 100644 --- a/apps/web/src/timeline/components/audio-volume-line.tsx +++ b/apps/web/src/timeline/components/audio-volume-line.tsx @@ -3,13 +3,12 @@ import { useCallback, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useEditor } from "@/editor/use-editor"; -import { DEFAULTS } from "@/timeline/defaults"; import { getDbFromLinePos, getLinePosFromDb, } from "@/timeline/audio-display"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; -import { hasAnimatedVolume } from "@/timeline/audio-state"; +import { getElementVolume, hasAnimatedVolume } from "@/timeline/audio-state"; import type { AudioElement } from "@/timeline/types"; import { clamp, @@ -60,10 +59,8 @@ export function AudioVolumeLine({ const editor = useEditor(); const surfaceRef = useRef<HTMLDivElement>(null); const activePointerIdRef = useRef<number | null>(null); - const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume); - const lastPreviewVolumeRef = useRef( - element.volume ?? DEFAULTS.element.volume, - ); + const startVolumeRef = useRef(getElementVolume({ element })); + const lastPreviewVolumeRef = useRef(getElementVolume({ element })); const hasChangedRef = useRef(false); const [isDragging, setIsDragging] = useState(false); const [tooltipClientPos, setTooltipClientPos] = useState<{ @@ -72,7 +69,7 @@ export function AudioVolumeLine({ } | null>(null); const hasAnimatedEnvelope = hasAnimatedVolume({ element }); - const currentVolume = element.volume ?? DEFAULTS.element.volume; + const currentVolume = getElementVolume({ element }); const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`; const volumeLabel = `${formatNumberForDisplay({ @@ -96,7 +93,7 @@ export function AudioVolumeLine({ { trackId, elementId: element.id, - updates: { volume: nextVolume }, + updates: { params: { volume: nextVolume } }, }, ], }); @@ -242,8 +239,7 @@ export function AudioVolumeLine({ )} style={{ top: lineTop }} /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- custom drag widget for clip volume; pointer events drive the interaction, the click/mousedown handlers are propagation-stoppers. The a11y-correct long-term shape is <input type="range"> with a custom thumb. */} <div className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto" style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }} diff --git a/apps/web/src/timeline/components/audio-waveform.tsx b/apps/web/src/timeline/components/audio-waveform.tsx index 7777bc09..87178f17 100644 --- a/apps/web/src/timeline/components/audio-waveform.tsx +++ b/apps/web/src/timeline/components/audio-waveform.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useResizeObserver } from "@/hooks/use-resize-observer"; import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme"; import { @@ -72,21 +73,19 @@ export function AudioWaveform({ const canvasRef = useRef<HTMLCanvasElement>(null); const containerRef = useRef<HTMLDivElement>(null); const summaryRef = useRef<SourceWaveformSummary | null>(null); - const gainSamplesRef = useRef<number[] | undefined>(gainSamples); - const pixelsPerSecondRef = useRef<number>(pixelsPerSecond); - const clipDurationSecRef = useRef<number>(clipDurationSec); - const retimeRef = useRef<RetimeConfig | undefined>(retime); - const sourceStartSecRef = useRef<number>(sourceStartSec); + const waveformConfigRef = useCommittedRef({ + gainSamples, + pixelsPerSecond, + clipDurationSec, + retime, + sourceStartSec, + color, + burnColor, + }); const scrollParentRef = useRef<HTMLElement | null>(null); const heightRef = useRef<number>(0); const lastRenderSignatureRef = useRef<string | null>(null); - gainSamplesRef.current = gainSamples; - pixelsPerSecondRef.current = pixelsPerSecond; - clipDurationSecRef.current = clipDurationSec; - retimeRef.current = retime; - sourceStartSecRef.current = sourceStartSec; - const clearCanvas = useCallback(() => { const canvas = canvasRef.current; if (!canvas) { @@ -142,13 +141,19 @@ export function AudioWaveform({ return; } + const { + gainSamples: gainSamplesValue, + pixelsPerSecond: pixelsPerSecondValue, + clipDurationSec: clipDurationSecValue, + retime: retimeValue, + sourceStartSec: sourceStartSecValue, + color: colorValue, + burnColor: burnColorValue, + } = waveformConfigRef.current; const dpr = window.devicePixelRatio || 1; const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr)); const canvasH = Math.max(1, Math.round(height * dpr)); const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP)); - const pixelsPerSecondValue = pixelsPerSecondRef.current; - const clipDurationSecValue = clipDurationSecRef.current; - const samples = gainSamplesRef.current; const renderSignature = JSON.stringify({ elementWidth, clipLeft, @@ -159,16 +164,16 @@ export function AudioWaveform({ barCount, dpr, clipDurationSec: clipDurationSecValue, - sourceStartSec: sourceStartSecRef.current, + sourceStartSec: sourceStartSecValue, pixelsPerSecond: pixelsPerSecondValue, - retime: retimeRef.current ?? null, + retime: retimeValue ?? null, summarySourceKey: summary.sourceKey, summarySampleRate: summary.sampleRate, summaryTotalSamples: summary.totalSamples, summaryBucketSize: summary.bucketSize, - gainSamples: samples ?? null, - color, - burnColor, + gainSamples: gainSamplesValue ?? null, + color: colorValue, + burnColor: burnColorValue, }); if (lastRenderSignatureRef.current === renderSignature) { return; @@ -190,8 +195,8 @@ export function AudioWaveform({ barCount, pixelsPerSecond: pixelsPerSecondValue, clipDurationSec: clipDurationSecValue, - sourceStartSec: sourceStartSecRef.current, - retime: retimeRef.current, + sourceStartSec: sourceStartSecValue, + retime: retimeValue, sampleRate: summary.sampleRate, maxSampleExclusive: summary.totalSamples, barStepPx: BAR_STEP, @@ -218,9 +223,9 @@ export function AudioWaveform({ Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue), ); const gain = - samples != null + gainSamplesValue != null ? sampleGainAtClipTime({ - samples, + samples: gainSamplesValue, clipTimeSec: clipCenterSec, clipDurationSec: clipDurationSecValue, }) @@ -243,7 +248,7 @@ export function AudioWaveform({ const deviceTop = Math.round((height - barH) * backingScaleY); const deviceHeight = Math.max(1, clipBottom - deviceTop); - ctx.fillStyle = color; + ctx.fillStyle = colorValue; ctx.fillRect( deviceLeft, deviceTop, @@ -253,7 +258,7 @@ export function AudioWaveform({ if (outputAmplitude > 1) { const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY)); - ctx.fillStyle = burnColor; + ctx.fillStyle = burnColorValue; ctx.fillRect( deviceLeft, deviceTop, @@ -262,7 +267,7 @@ export function AudioWaveform({ ); } } - }, [burnColor, clearCanvas, color]); + }, [clearCanvas, waveformConfigRef]); useEffect(() => { let isCancelled = false; @@ -296,7 +301,6 @@ export function AudioWaveform({ }; }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]); - // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change. useLayoutEffect(() => { drawVisible(); }, [ @@ -306,6 +310,8 @@ export function AudioWaveform({ clipDurationSec, retime, sourceStartSec, + color, + burnColor, ]); useEffect(() => { diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts index be2495e4..f68a4b72 100644 --- a/apps/web/src/timeline/components/drop-target.ts +++ b/apps/web/src/timeline/components/drop-target.ts @@ -3,7 +3,12 @@ import type { ComputeDropTargetParams, DropTarget } from "@/timeline"; import { resolveTrackPlacement } from "@/timeline/placement"; import { TIMELINE_TRACK_GAP_PX } from "./layout"; import { getTrackHeight } from "./track-layout"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + mediaTime, + type MediaTime, + roundMediaTime, + TICKS_PER_SECOND, +} from "@/wasm"; function findElementAtPosition({ mouseX, @@ -20,9 +25,11 @@ function findElementAtPosition({ pixelsPerSecond: number; zoomLevel: number; }): { elementId: string; trackId: string } | null { - const time = Math.round( - (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); + const time = mediaTime({ + ticks: Math.round( + (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, + ), + }); const track = tracks[trackIndex]; if (!track || !("elements" in track)) return null; @@ -82,7 +89,7 @@ const EMPTY_TARGET_ELEMENT = null; function fallbackNewTrackDropTarget({ xPosition, }: { - xPosition: number; + xPosition: MediaTime; }): DropTarget { return { trackIndex: 0, @@ -111,13 +118,16 @@ export function computeDropTarget({ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const mainTrackIndex = tracks.overlay.length; const xPosition = - typeof startTimeOverride === "number" + startTimeOverride !== undefined ? startTimeOverride : isExternalDrop ? playheadTime - : Math.round( - Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); + : mediaTime({ + ticks: Math.round( + Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * + TICKS_PER_SECOND, + ), + }); if (orderedTracks.length === 0) { const placementResult = resolveTrackPlacement({ @@ -221,7 +231,10 @@ export function computeDropTarget({ } if (placementResult.kind === "existingTrack") { - const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition; + const adjustedXPosition = + placementResult.adjustedStartTime !== undefined + ? roundMediaTime({ time: placementResult.adjustedStartTime }) + : xPosition; return { trackIndex: placementResult.trackIndex, diff --git a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx index 7d77b985..ff4219c5 100644 --- a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx +++ b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx @@ -2,6 +2,7 @@ import { useRef, useState, type PointerEvent } from "react"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { getBezierPoint } from "@/animation/bezier"; import type { NormalizedCubicBezier } from "@/animation/types"; import { cn } from "@/utils/ui"; @@ -87,9 +88,7 @@ export function BezierGraph({ const svgRef = useRef<SVGSVGElement>(null); const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null); const isShiftPressedRef = useShiftKey(); - const latestValueRef = useRef(value); - - latestValueRef.current = value; + const latestValueRef = useCommittedRef(value); function getPointerPosition({ event, diff --git a/apps/web/src/timeline/components/graph-editor/session.ts b/apps/web/src/timeline/components/graph-editor/session.ts index d7c6b001..c5d1c133 100644 --- a/apps/web/src/timeline/components/graph-editor/session.ts +++ b/apps/web/src/timeline/components/graph-editor/session.ts @@ -1,11 +1,12 @@ import { getCurveHandlesForNormalizedCubicBezier, getEditableScalarChannels, - getEasingModeForKind, getNormalizedCubicBezierForScalarSegment, getScalarKeyframeContext, updateScalarKeyframeCurve, } from "@/animation"; +import { getChannelsFromData } from "@/animation/channel-data"; +import { isScalarChannel } from "@/animation/interpolation"; import type { AnimationPath, ElementAnimations, @@ -143,12 +144,9 @@ function findKeyframeTime({ propertyPath: AnimationPath; keyframeId: string; }): number | null { - const binding = animations.bindings[propertyPath]; - if (!binding) return null; - - for (const component of binding.components) { - const channel = animations.channels[component.channelId]; - if (channel?.kind !== "scalar") continue; + const data = animations[propertyPath]; + for (const channel of getChannelsFromData({ data })) { + if (!channel || !isScalarChannel(channel)) continue; const key = channel.keys.find((k) => k.id === keyframeId); if (key !== undefined) return key.time; } @@ -300,8 +298,7 @@ function resolvePropertySelection({ } } - const { binding: resolvedBinding, channels: scalarChannels } = scalarResult; - const easingMode = getEasingModeForKind(resolvedBinding.kind); + const { easingMode, channels: scalarChannels } = scalarResult; const contexts = scalarChannels.flatMap((channel) => { const context = getScalarKeyframeContext({ animations: element.animations, diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index 88fb93b7..8edbb6fc 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -29,6 +29,8 @@ import { useState, type ReactNode, } from "react"; +import { useContainerSize } from "@/hooks/use-container-size"; +import type { MediaTime } from "@/wasm"; import type { ElementDragView, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; @@ -79,6 +81,7 @@ import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bott import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize"; import { useTimelineStore } from "@/timeline/timeline-store"; import { useEditor } from "@/editor/use-editor"; +import { useScrollPosition } from "@/timeline/hooks/use-scroll-position"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/actions"; @@ -132,7 +135,7 @@ export function Timeline() { [scene], ); const mainTrackId = scene?.tracks.main.id ?? null; - const seek = (time: number) => editor.playback.seek({ time }); + const seek = (time: MediaTime) => editor.playback.seek({ time }); const timelineRef = useRef<HTMLDivElement>(null); const timelineHeaderRef = useRef<HTMLDivElement>(null); @@ -147,15 +150,25 @@ export function Timeline() { const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( null, ); + const { width: tracksContainerWidth } = useContainerSize({ + containerRef: tracksContainerRef, + }); + const { height: timelineHeaderHeightValue } = useContainerSize({ + containerRef: timelineHeaderRef, + }); + const { viewportWidth: tracksViewportWidth } = useScrollPosition({ + scrollRef: tracksScrollRef, + }); const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { setCurrentSnapPoint(snapPoint); }, []); const timelineDuration = timeline.getTotalDuration() || 0; + const containerWidth = tracksContainerWidth || FALLBACK_CONTAINER_WIDTH; const minZoomLevel = getTimelineZoomMin({ duration: timelineDuration, - containerWidth: tracksContainerRef.current?.clientWidth, + containerWidth, }); const savedViewState = editor.project.getTimelineViewState(); @@ -289,12 +302,12 @@ export function Timeline() { const { dragView, handleElementMouseDown, handleElementClick } = useElementInteraction({ - zoomLevel, - tracksContainerRef, - tracksScrollRef, - snappingEnabled, - onSnapPointChange: handleSnapPointChange, - }); + zoomLevel, + tracksContainerRef, + tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); const isElementDragging = dragView.kind === "dragging"; const { @@ -357,8 +370,6 @@ export function Timeline() { }, }); - const containerWidth = - tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel, @@ -372,11 +383,8 @@ export function Timeline() { contentWidth + paddingPx, containerWidth, ); - const tracksViewportWidth = - tracksScrollRef.current?.clientWidth ?? - tracksContainerRef.current?.clientWidth ?? - containerWidth; - const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth; + const hasHorizontalScrollbar = + dynamicTimelineWidth > (tracksViewportWidth || containerWidth); useEdgeAutoScroll({ isActive: bookmarkDragState.isDragging, @@ -418,8 +426,7 @@ export function Timeline() { }); const timelineHeaderHeight = - (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + - TIMELINE_CONTENT_TOP_PADDING_PX || 0; + timelineHeaderHeightValue + TIMELINE_CONTENT_TOP_PADDING_PX; return ( <section @@ -449,10 +456,7 @@ export function Timeline() { ref={tracksContainerRef} > <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} + bounds={selectionBox?.bounds ?? null} /> <DragLine dropTarget={dropTarget} @@ -508,8 +512,7 @@ export function Timeline() { className="flex min-h-full flex-col" style={{ width: `${dynamicTimelineWidth}px` }} > - {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (tracks container background); direct-target clicks here originate box-select or clear selection. Keyboard control is global timeline shortcuts. */} <div className="relative shrink-0" style={{ @@ -777,8 +780,8 @@ function TimelineTrackRows({ const draggingElementIds = useMemo( () => dragView.kind === "dragging" - ? dragView.memberTimeOffsets - : (null as ReadonlyMap<string, number> | null), + ? dragView.memberTimeOffsets + : (null as ReadonlyMap<string, MediaTime> | null), [dragView], ); const sortedTracks = useMemo(() => { @@ -890,9 +893,10 @@ function TimelineGutter({ onMouseDown: (event: React.MouseEvent) => void; onClick: (event: React.MouseEvent) => void; }) { - // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system - // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system - return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />; + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (empty space below tracks); clicks here clear selection. Keyboard control is global timeline shortcuts. + <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} /> + ); } function TrackIcon({ track }: { track: TimelineTrack }) { diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index 35785332..1b549a15 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -31,6 +31,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import type { SelectionBoxBounds } from "@/selection/types"; import type { TimelineElement as TimelineElementType, TimelineTrack, @@ -46,10 +47,10 @@ import { getSourceAudioActionLabel, isSourceAudioSeparated, } from "@/timeline/audio-separation"; -import { buildWaveformGainSamples } from "@/timeline/audio-state"; +import { buildWaveformGainSamples, isElementMuted } from "@/timeline/audio-state"; import { getTimelinePixelsPerSecond } from "@/timeline"; import { buildWaveformSourceKey } from "@/media/waveform-summary"; -import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; import { getActionDefinition, type TAction, @@ -96,7 +97,7 @@ const PixelsPerSecondContext = createContext<number | null>(null); const THUMBNAIL_ASPECT_RATIO = 16 / 9; interface KeyframeIndicator { - time: number; + time: MediaTime; offsetPx: number; keyframes: SelectedKeyframeRef[]; } @@ -112,11 +113,11 @@ export function buildKeyframeIndicator({ keyframe: ElementKeyframe; trackId: string; elementId: string; - displayedStartTime: number; + displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; }): { - time: number; + time: MediaTime; offsetPx: number; keyframeRef: SelectedKeyframeRef; } { @@ -149,7 +150,7 @@ export function getKeyframeIndicators({ keyframes: ElementKeyframe[]; trackId: string; elementId: string; - displayedStartTime: number; + displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; elementWidth: number; @@ -158,7 +159,7 @@ export function getKeyframeIndicators({ return []; } - const keyframesByTime = new Map<number, KeyframeIndicator>(); + const keyframesByTime = new Map<MediaTime, KeyframeIndicator>(); for (const keyframe of keyframes) { const indicator = buildKeyframeIndicator({ keyframe, @@ -185,7 +186,7 @@ export function getKeyframeIndicators({ } export function getDisplayShortcut({ action }: { action: TAction }) { - const { defaultShortcuts } = getActionDefinition({ action }); + const defaultShortcuts = getActionDefinition({ action }).defaultShortcuts; if (!defaultShortcuts?.length) { return ""; } @@ -206,14 +207,14 @@ interface TimelineElementProps { track: TimelineTrack; side: "left" | "right"; }) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; dragView: ElementDragView; isDropTarget?: boolean; } @@ -263,7 +264,7 @@ export function TimelineElement({ : 0; const elementStartTime = isDragging && isBeingDragged - ? dragView.currentTime + dragTimeOffset + ? addMediaTime({ a: dragView.currentTime, b: dragTimeOffset }) : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = renderElement.duration; @@ -334,7 +335,7 @@ export function TimelineElement({ } }; - const isMuted = canElementHaveAudio(element) && element.muted === true; + const isMuted = canElementHaveAudio(element) && isElementMuted({ element }); const canToggleCurrentSourceAudio = selectedElements.length === 1 && isCurrentElementSelected && @@ -385,7 +386,7 @@ export function TimelineElement({ ? `${baseTrackHeight + expansionHeight}px` : "100%", transform: - isBeingDragged && isDragging + isDragging && isBeingDragged ? `translate3d(0, ${dragOffsetY}px, 0)` : undefined, }} @@ -530,14 +531,14 @@ function ElementInner({ isExpanded: boolean; baseTrackHeight: number; expandedContent: React.ReactNode; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + }) => void; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; @@ -578,8 +579,8 @@ function ElementInner({ type="button" tabIndex={-1} className="absolute inset-0 size-full flex flex-col" - onClick={(event) => onElementClick(event, element)} - onMouseDown={(event) => onElementMouseDown(event, element)} + onClick={(event) => onElementClick({ event, element })} + onMouseDown={(event) => onElementMouseDown({ event, element })} > <div className={cn( @@ -664,7 +665,7 @@ function KeyframeIndicators({ }: { indicators: KeyframeIndicator[]; dragState: KeyframeDragState; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; onKeyframeMouseDown: (params: { event: React.MouseEvent; @@ -674,13 +675,13 @@ function KeyframeIndicators({ event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; + indicatorTime: MediaTime; }) => void; getVisualOffsetPx: (params: { - indicatorTime: number; + indicatorTime: MediaTime; indicatorOffsetPx: number; isBeingDragged: boolean; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; }) => number; }) { @@ -758,7 +759,7 @@ function ExpandedKeyframeLanes({ keyframes: ElementKeyframe[]; trackId: string; elementId: string; - displayedStartTime: number; + displayedStartTime: MediaTime; zoomLevel: number; elementLeft: number; keyframeDragState: KeyframeDragState; @@ -770,22 +771,20 @@ function ExpandedKeyframeLanes({ onLaneMouseDown: (event: React.MouseEvent) => void; onLaneClick: (event: React.MouseEvent) => void; selectionBox: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; + bounds: SelectionBoxBounds; } | null; isBoxSelecting: boolean; onKeyframeClick: (params: { event: React.MouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; + indicatorTime: MediaTime; }) => void; getVisualOffsetPx: (params: { - indicatorTime: number; + indicatorTime: MediaTime; indicatorOffsetPx: number; isBeingDragged: boolean; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; }) => number; }) { @@ -808,8 +807,7 @@ function ExpandedKeyframeLanes({ ); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface - // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- spatial gesture surface (keyframe lanes); keyboard control over keyframes is via global timeline shortcuts, not per-element focus. <div ref={containerRef} className="relative flex flex-col" @@ -893,14 +891,7 @@ function ExpandedKeyframeLanes({ </div> ); })} - {selectionBox && ( - <SelectionBox - startPos={selectionBox.startPos} - currentPos={selectionBox.currentPos} - containerRef={containerRef} - isActive={selectionBox.isActive} - /> - )} + {selectionBox && <SelectionBox bounds={selectionBox.bounds} />} </div> ); } @@ -917,7 +908,9 @@ function TextElementContent({ }) { return ( <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white">{element.content}</span> + <span className="truncate text-xs text-white"> + {typeof element.params.content === "string" ? element.params.content : ""} + </span> </div> ); } @@ -1073,7 +1066,7 @@ function EffectsButton({ editor.selection.setSelectedElements({ elements: [{ trackId: track.id, elementId: element.id }], }); - setActiveTab(element.type, "effects"); + setActiveTab({ elementType: element.type, tabId: "effects" }); }; return ( diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx index 4dd3329f..6d77789e 100644 --- a/apps/web/src/timeline/components/timeline-playhead.tsx +++ b/apps/web/src/timeline/components/timeline-playhead.tsx @@ -1,13 +1,22 @@ "use client"; import { useRef } from "react"; +import { useContainerSize } from "@/hooks/use-container-size"; import { getCenteredLineLeft, TIMELINE_INDICATOR_LINE_WIDTH_PX, timelineTimeToSnappedPixels, } from "@/timeline"; +import { useScrollPosition } from "@/timeline/hooks/use-scroll-position"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { useEditor } from "@/editor/use-editor"; import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout"; import { TIMELINE_LAYERS } from "./layers"; @@ -45,11 +54,14 @@ export function TimelinePlayhead({ tracksScrollRef, playheadRef, }); + const { height: timelineHeight } = useContainerSize({ containerRef: timelineRef }); + const { height: tracksHeight } = useContainerSize({ + containerRef: tracksScrollRef, + }); + const { scrollLeft } = useScrollPosition({ scrollRef: tracksScrollRef }); const timelineContainerHeight = - timelineRef.current?.clientHeight ?? - tracksScrollRef.current?.clientHeight ?? - 400; + timelineHeight || tracksHeight || 400; const totalHeight = Math.max( 0, timelineContainerHeight - @@ -61,7 +73,6 @@ export function TimelinePlayhead({ time: currentTime, zoomLevel, }); - const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0; const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft; @@ -72,17 +83,24 @@ export function TimelinePlayhead({ event.preventDefault(); const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); + const ticksPerFrame = mediaTime({ + ticks: Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ), + }); const direction = event.key === "ArrowRight" ? 1 : -1; const now = editor.playback.getCurrentTime(); - const nextTime = Math.max( - 0, - Math.min(duration, now + direction * ticksPerFrame), - ); + const nextTime = + direction > 0 + ? addMediaTime({ a: now, b: ticksPerFrame }) + : subMediaTime({ a: now, b: ticksPerFrame }); - editor.playback.seek({ time: nextTime }); + editor.playback.seek({ + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: duration < nextTime ? duration : nextTime, + }), + }); }; return ( diff --git a/apps/web/src/timeline/components/timeline-ruler.tsx b/apps/web/src/timeline/components/timeline-ruler.tsx index e2a379ee..ccacdae3 100644 --- a/apps/web/src/timeline/components/timeline-ruler.tsx +++ b/apps/web/src/timeline/components/timeline-ruler.tsx @@ -1,4 +1,4 @@ -import { type JSX, useLayoutEffect, useRef } from "react"; +import { type JSX } from "react"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { mediaTimeToSeconds } from "opencut-wasm"; import { TICKS_PER_SECOND } from "@/wasm"; @@ -51,20 +51,9 @@ export function TimelineRuler({ scrollRef: tracksScrollRef, }); - /** - * widens the virtualization buffer during zoom transitions. - * useScrollPosition lags one frame behind the scroll adjustment - * that useLayoutEffect applies after a zoom change. - */ - const prevZoomRef = useRef(zoomLevel); - const isZoomTransition = zoomLevel !== prevZoomRef.current; - const bufferPx = isZoomTransition - ? Math.max(200, (scrollLeft + viewportWidth) * 0.15) - : 200; - - useLayoutEffect(() => { - prevZoomRef.current = zoomLevel; - }, [zoomLevel]); + // Keep extra buffer because zoom layout and scroll position can briefly + // settle on different frames. + const bufferPx = Math.max(200, (scrollLeft + viewportWidth) * 0.15); const visibleStartTimeSeconds = Math.max( 0, diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx index 7608e6df..2603f511 100644 --- a/apps/web/src/timeline/components/timeline-track.tsx +++ b/apps/web/src/timeline/components/timeline-track.tsx @@ -62,7 +62,7 @@ export function TimelineTrackContent({ onTrackMouseDown?.(event); }} /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- spatial gesture surface; the wrapping <button> handles keyboard track selection, this <div> only forwards background clicks for box-select / deselect. */} <div className="relative h-full min-w-full" style={{ zIndex: TIMELINE_LAYERS.trackContent }} @@ -96,10 +96,10 @@ export function TimelineTrackContent({ onResizeStart={({ event, element, side }) => onResizeStart({ event, element, track, side }) } - onElementMouseDown={(event, element) => + onElementMouseDown={({ event, element }) => onElementMouseDown({ event, element, track }) } - onElementClick={(event, element) => + onElementClick={({ event, element }) => onElementClick({ event, element, track }) } dragView={dragView} diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts index cd03300e..a419101f 100644 --- a/apps/web/src/timeline/controllers/drag-drop-controller.ts +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -6,7 +6,7 @@ import { toElementDurationTicks, } from "@/timeline/creation"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { roundToFrame, type FrameRate } from "opencut-wasm"; +import type { FrameRate } from "opencut-wasm"; import { buildTextElement, buildGraphicElement, @@ -30,6 +30,7 @@ import type { import type { TimelineDragData } from "@/timeline/drag"; import type { MediaAsset } from "@/media/types"; import type { ProcessedMediaAsset } from "@/media/processing"; +import { roundFrameTime, type MediaTime } from "@/wasm"; // --- Config --- @@ -41,7 +42,7 @@ export interface DragDropConfig { getActiveProjectFps: () => FrameRate | null; getActiveProjectId: () => string | null; getSceneTracks: () => SceneTracks; - getCurrentPlayheadTime: () => number; + getCurrentPlayheadTime: () => MediaTime; getMediaAssets: () => MediaAsset[]; dragSource: TimelineDragSource; addMediaAsset: (args: { @@ -116,7 +117,7 @@ function getDurationForDrag({ }: { dragData: TimelineDragData; mediaAssets: MediaAsset[]; -}): number { +}): MediaTime { if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION; const media = mediaAssets.find((asset) => asset.id === dragData.id); return toElementDurationTicks({ seconds: media?.duration }); @@ -225,8 +226,7 @@ export class DragDropController { const fps = this.config.getActiveProjectFps(); target.xPosition = fps - ? (roundToFrame({ time: target.xPosition, rate: fps }) ?? - target.xPosition) + ? roundFrameTime({ time: target.xPosition, fps }) : target.xPosition; this.setOver({ dropTarget: target, elementType }); @@ -326,7 +326,10 @@ export class DragDropController { trackType: TrackType; }): void { if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + const addTrackCmd = new AddTrackCommand({ + type: trackType, + index: target.trackIndex, + }); this.config.executeCommand( new BatchCommand([ addTrackCmd, @@ -382,7 +385,10 @@ export class DragDropController { dragData: Extract<TimelineDragData, { type: "text" }>; }): void { const element = buildTextElement({ - raw: { name: dragData.name ?? "", content: dragData.content ?? "" }, + raw: { + name: dragData.name ?? "", + params: { content: dragData.content ?? "" }, + }, startTime: target.xPosition, }); this.insertAtTarget({ element, target, trackType: "text" }); diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts index cc19daaa..5afc3a93 100644 --- a/apps/web/src/timeline/controllers/element-interaction-controller.ts +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -7,9 +7,17 @@ import { type MoveGroup, } from "@/timeline/group-move"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + maxMediaTime, + type MediaTime, + mediaTime, + roundFrameTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame, type FrameRate } from "opencut-wasm"; +import type { FrameRate } from "opencut-wasm"; import { computeDropTarget } from "@/timeline/components/drop-target"; import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; @@ -52,7 +60,7 @@ export interface ElementSelectionApi { } export interface PlaybackReader { - getCurrentTime: () => number; + getCurrentTime: () => MediaTime; } export interface TimelineOps { @@ -86,8 +94,8 @@ interface MousedownSnapshot { readonly origin: Point; readonly elementId: string; readonly trackId: string; - readonly startElementTime: number; - readonly clickOffsetTime: number; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; readonly selectedElements: readonly ElementRef[]; } @@ -100,7 +108,7 @@ interface DragProgress { // indicator, drop-line, commit path) see the same entity every frame // instead of a churning UUID. reservedNewTrackIds: readonly string[]; - currentTime: number; + currentTime: MediaTime; currentMouseX: number; currentMouseY: number; groupMoveResult: GroupMoveResult | null; @@ -116,20 +124,27 @@ const IDLE_VIEW: ElementDragView = { kind: "idle" }; // --- Pure helpers --- -function pixelToClickOffsetTime( - clientX: number, - elementRect: DOMRect, - zoomLevel: number, -): number { +function pixelToClickOffsetTime({ + clientX, + elementRect, + zoomLevel, +}: { + clientX: number; + elementRect: DOMRect; + zoomLevel: number; +}): MediaTime { const clickOffsetX = clientX - elementRect.left; const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); } -function verticalDirection( - startMouseY: number, - currentMouseY: number, -): "up" | "down" | null { +function verticalDirection({ + startMouseY, + currentMouseY, +}: { + startMouseY: number; + currentMouseY: number; +}): "up" | "down" | null { if (currentMouseY < startMouseY) return "up"; if (currentMouseY > startMouseY) return "down"; return null; @@ -139,7 +154,13 @@ function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] { return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; } -function movedPastDragThreshold(current: Point, origin: Point): boolean { +function movedPastDragThreshold({ + current, + origin, +}: { + current: Point; + origin: Point; +}): boolean { return ( Math.abs(current.x - origin.x) > TIMELINE_DRAG_THRESHOLD_PX || Math.abs(current.y - origin.y) > TIMELINE_DRAG_THRESHOLD_PX @@ -156,17 +177,20 @@ function frameSnappedMouseTime({ clientX: number; scrollContainer: HTMLDivElement; zoomLevel: number; - clickOffsetTime: number; + clickOffsetTime: MediaTime; fps: FrameRate; -}): number { +}): MediaTime { const mouseTime = getMouseTimeFromClientX({ clientX, containerRect: scrollContainer.getBoundingClientRect(), zoomLevel, scrollLeft: scrollContainer.scrollLeft, }); - const adjusted = Math.max(0, mouseTime - clickOffsetTime); - return roundToFrame({ time: adjusted, rate: fps }) ?? adjusted; + const adjusted = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ a: mouseTime, b: clickOffsetTime }), + }); + return roundFrameTime({ time: adjusted, fps }); } function resolveDropTarget({ @@ -187,7 +211,7 @@ function resolveDropTarget({ tracks: SceneTracks; viewport: ViewportAdapter; zoomLevel: number; - snappedTime: number; + snappedTime: MediaTime; verticalDragDirection: "up" | "down" | null; }): DropTarget | null { const containerRect = viewport @@ -231,7 +255,7 @@ function resolveGroupMoveForDrop({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; dropTarget: DropTarget; reservedNewTrackIds: readonly string[]; }): GroupMoveResult | null { @@ -286,7 +310,7 @@ export class ElementInteractionController { get view(): ElementDragView { if (this.session.kind !== "dragging") return IDLE_VIEW; const { mousedown, drag } = this.session; - const memberTimeOffsets = new Map<string, number>(); + const memberTimeOffsets = new Map<string, MediaTime>(); for (const member of drag.moveGroup.members) { memberTimeOffsets.set(member.elementId, member.timeOffset); } @@ -363,11 +387,11 @@ export class ElementInteractionController { elementId: element.id, trackId: track.id, startElementTime: element.startTime, - clickOffsetTime: pixelToClickOffsetTime( - event.clientX, - event.currentTarget.getBoundingClientRect(), - this.deps.viewport.getZoomLevel(), - ), + clickOffsetTime: pixelToClickOffsetTime({ + clientX: event.clientX, + elementRect: event.currentTarget.getBoundingClientRect(), + zoomLevel: this.deps.viewport.getZoomLevel(), + }), selectedElements, }, }; @@ -426,10 +450,13 @@ export class ElementInteractionController { this.notify(); } - private snapResult( - frameSnappedTime: number, - group: MoveGroup, - ): { snappedTime: number; snapPoint: SnapPoint | null } { + private snapResult({ + frameSnappedTime, + group, + }: { + frameSnappedTime: MediaTime; + group: MoveGroup; + }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } { const { snap, input, scene, viewport, playback } = this.deps; if (!snap.isEnabled() || input.isShiftHeld()) { @@ -461,7 +488,7 @@ export class ElementInteractionController { clientY: number; mousedown: MousedownSnapshot; drag: DragProgress; - snappedTime: number; + snappedTime: MediaTime; }): void { const { scene, viewport } = this.deps; const tracks = scene.getTracks(); @@ -476,7 +503,10 @@ export class ElementInteractionController { viewport, zoomLevel, snappedTime, - verticalDragDirection: verticalDirection(mousedown.origin.y, clientY), + verticalDragDirection: verticalDirection({ + startMouseY: mousedown.origin.y, + currentMouseY: clientY, + }), }); const nextGroupMoveResult = anchorDropTarget @@ -532,7 +562,12 @@ export class ElementInteractionController { clientY: number; scrollContainer: HTMLDivElement; }): void { - if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + if ( + !movedPastDragThreshold({ + current: { x: clientX, y: clientY }, + origin: mousedown.origin, + }) + ) { return; } @@ -557,10 +592,10 @@ export class ElementInteractionController { clickOffsetTime: mousedown.clickOffsetTime, fps, }); - const { snappedTime, snapPoint } = this.snapResult( + const { snappedTime, snapPoint } = this.snapResult({ frameSnappedTime, - moveGroup, - ); + group: moveGroup, + }); // Ensure the anchor is selected before we render the drag — covers the // case where the selection store hasn't committed the mousedown-time @@ -621,10 +656,10 @@ export class ElementInteractionController { clickOffsetTime: mousedown.clickOffsetTime, fps, }); - const { snappedTime, snapPoint } = this.snapResult( + const { snappedTime, snapPoint } = this.snapResult({ frameSnappedTime, - drag.moveGroup, - ); + group: drag.moveGroup, + }); drag.currentTime = snappedTime; drag.currentMouseX = clientX; @@ -655,7 +690,12 @@ export class ElementInteractionController { // If the drag returned within the click threshold of its origin, treat // this as a cancel rather than a commit — the user dragged then put the // element back. - if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + if ( + !movedPastDragThreshold({ + current: { x: clientX, y: clientY }, + origin: mousedown.origin, + }) + ) { this.lastGestureWasDrag = false; this.finishSession(); return; diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts index 51c60dee..e3a9cd96 100644 --- a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts +++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts @@ -1,7 +1,16 @@ import type { MouseEvent as ReactMouseEvent } from "react"; -import { roundToFrame, snappedSeekTime, type FrameRate } from "opencut-wasm"; +import type { FrameRate } from "opencut-wasm"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + clampMediaTime, + type MediaTime, + mediaTime, + roundFrameTicks, + snapSeekMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; import { timelineTimeToSnappedPixels } from "@/timeline"; import { getKeyframeById } from "@/animation"; @@ -48,7 +57,7 @@ export interface KeyframeDragConfig { zoomLevel: number; getFps: () => FrameRate | null; element: TimelineElement; - displayedStartTime: number; + displayedStartTime: MediaTime; selectedKeyframes: SelectedKeyframeRef[]; isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean; setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void; @@ -62,8 +71,8 @@ export interface KeyframeDragConfig { isAdditive: boolean; }) => void; executeCommand: (command: Command) => void; - seek: (args: { time: number }) => void; - getTotalDuration: () => number; + seek: (args: { time: MediaTime }) => void; + getTotalDuration: () => MediaTime; } export interface KeyframeDragConfigRef { @@ -161,7 +170,7 @@ export class KeyframeDragController { event: ReactMouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; + indicatorTime: MediaTime; }): void { event.stopPropagation(); @@ -174,14 +183,18 @@ export class KeyframeDragController { const { displayedStartTime, getFps, getTotalDuration, seek } = this.config; const fps = getFps(); + const absoluteIndicatorTime = addMediaTime({ + a: displayedStartTime, + b: indicatorTime, + }); const seekTime = fps != null - ? (snappedSeekTime({ - time: displayedStartTime + indicatorTime, + ? snapSeekMediaTime({ + time: absoluteIndicatorTime, duration: getTotalDuration(), - rate: fps, - }) ?? displayedStartTime + indicatorTime) - : displayedStartTime + indicatorTime; + fps, + }) + : absoluteIndicatorTime; seek({ time: seekTime }); if (event.shiftKey) { @@ -206,24 +219,23 @@ export class KeyframeDragController { displayedStartTime, elementLeft, }: { - indicatorTime: number; + indicatorTime: MediaTime; indicatorOffsetPx: number; isBeingDragged: boolean; - displayedStartTime: number; + displayedStartTime: MediaTime; elementLeft: number; }): number { if (!isBeingDragged || this.session.kind !== "active") return indicatorOffsetPx; - const clampedTime = Math.max( - 0, - Math.min( - this.config.element.duration, - indicatorTime + this.session.deltaTicks, - ), - ); + const deltaTime = mediaTime({ ticks: this.session.deltaTicks }); + const clampedTime = clampMediaTime({ + time: addMediaTime({ a: indicatorTime, b: deltaTime }), + min: ZERO_MEDIA_TIME, + max: this.config.element.duration, + }); return ( timelineTimeToSnappedPixels({ - time: displayedStartTime + clampedTime, + time: addMediaTime({ a: displayedStartTime, b: clampedTime }), zoomLevel: this.config.zoomLevel, }) - elementLeft ); @@ -270,10 +282,14 @@ export class KeyframeDragController { elementId: ref.elementId, propertyPath: ref.propertyPath, keyframeId: ref.keyframeId, - nextTime: Math.max( - 0, - Math.min(element.duration, keyframe.time + deltaTicks), - ), + nextTime: clampMediaTime({ + time: addMediaTime({ + a: keyframe.time, + b: mediaTime({ ticks: deltaTicks }), + }), + min: ZERO_MEDIA_TIME, + max: element.duration, + }), }), ]; }); @@ -313,8 +329,7 @@ export class KeyframeDragController { ((clientX - this.session.startMouseX) / pixelsPerSecond) * TICKS_PER_SECOND, ); - this.session.deltaTicks = - roundToFrame({ time: rawDeltaTicks, rate: fps }) ?? rawDeltaTicks; + this.session.deltaTicks = roundFrameTicks({ ticks: rawDeltaTicks, fps }); this.notify(); } diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts index d2b7d82f..e914ff21 100644 --- a/apps/web/src/timeline/controllers/playhead-controller.ts +++ b/apps/web/src/timeline/controllers/playhead-controller.ts @@ -1,6 +1,11 @@ import type { MouseEvent as ReactMouseEvent } from "react"; -import { snappedSeekTime, type FrameRate } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/wasm"; +import type { FrameRate } from "opencut-wasm"; +import { + mediaTime, + snapSeekMediaTime, + TICKS_PER_SECOND, + type MediaTime, +} from "@/wasm"; import { buildTimelineSnapPoints, getTimelineSnapThresholdInTicks, @@ -26,7 +31,7 @@ interface ScrubSession { /** True once the mouse has moved during a ruler drag. */ hasMoved: boolean; /** Most recent frame-snapped time set by scrub(). */ - currentTime: number | null; + currentTime: MediaTime | null; } type Session = { kind: "idle" } | ScrubSession; @@ -35,7 +40,7 @@ type Session = { kind: "idle" } | ScrubSession; export interface PlayheadConfig { zoomLevel: number; - duration: number; + duration: MediaTime; getActiveProjectFps: () => FrameRate | null; isShiftHeld: () => boolean; getIsPlaying: () => boolean; @@ -45,12 +50,12 @@ export interface PlayheadConfig { getPlayheadEl: () => HTMLDivElement | null; getSceneTracks: () => SceneTracks; getSceneBookmarks: () => Bookmark[]; - seek: (time: number) => void; + seek: (time: MediaTime) => void; setScrubbing: (isScrubbing: boolean) => void; setTimelineViewState: (viewState: { zoomLevel: number; scrollLeft: number; - playheadTime: number; + playheadTime: MediaTime; }) => void; } @@ -69,8 +74,8 @@ function pixelToTime({ clientX: number; rulerEl: HTMLDivElement; zoomLevel: number; - duration: number; -}): number { + duration: MediaTime; +}): MediaTime { const rulerRect = rulerEl.getBoundingClientRect(); const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel }); const clampedX = Math.max( @@ -84,7 +89,7 @@ function pixelToTime({ clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), ), ); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); } // --- Controller --- @@ -159,7 +164,7 @@ export class PlayheadController { * Called on scroll and playback events to avoid React re-renders * during animation frame updates. */ - updatePlayheadLeft(time: number): void { + updatePlayheadLeft(time: MediaTime): void { const playheadEl = this.config.getPlayheadEl(); if (!playheadEl) return; @@ -175,7 +180,7 @@ export class PlayheadController { * Updates the playhead position and auto-scrolls to keep the playhead * visible during playback. */ - handlePlaybackUpdate(time: number): void { + handlePlaybackUpdate(time: MediaTime): void { this.updatePlayheadLeft(time); // Auto-scroll only during playback, not while scrubbing. @@ -244,8 +249,7 @@ export class PlayheadController { zoomLevel, duration, }); - const frameTime = - snappedSeekTime({ time: rawTime, duration, rate: fps }) ?? rawTime; + const frameTime = snapSeekMediaTime({ time: rawTime, duration, fps }); const time = (() => { if (!isElementSnappingEnabled || this.config.isShiftHeld()) diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts index cdf502a1..a1098915 100644 --- a/apps/web/src/timeline/controllers/resize-controller.ts +++ b/apps/web/src/timeline/controllers/resize-controller.ts @@ -1,6 +1,14 @@ import type { MouseEvent as ReactMouseEvent } from "react"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + type MediaTime, + mediaTime, + minMediaTime, + subMediaTime, + TICKS_PER_SECOND, +} from "@/wasm"; import { computeGroupResize, type GroupResizeMember, @@ -46,7 +54,7 @@ export interface ResizeConfig { snappingEnabled: boolean; isShiftHeld: () => boolean; getSceneTracks: () => SceneTracks; - getCurrentPlayheadTime: () => number; + getCurrentPlayheadTime: () => MediaTime; getActiveProjectFps: () => FrameRate | null; selectedElements: ElementRef[]; discardPreview: () => void; @@ -87,14 +95,32 @@ export function buildResizeMembers({ (el) => !selectedElementIds.has(el.id), ); const leftNeighborBound = otherElements - .filter((el) => el.startTime + el.duration <= element.startTime) - .reduce( - (bound, el) => Math.max(bound, el.startTime + el.duration), - -Infinity, - ); + .filter( + (el) => + addMediaTime({ a: el.startTime, b: el.duration }) <= + element.startTime, + ) + .reduce<MediaTime | null>((bound, el) => { + const elementEnd = addMediaTime({ + a: el.startTime, + b: el.duration, + }); + return bound === null + ? elementEnd + : maxMediaTime({ a: bound, b: elementEnd }); + }, null); const rightNeighborBound = otherElements - .filter((el) => el.startTime >= element.startTime + element.duration) - .reduce((bound, el) => Math.min(bound, el.startTime), Infinity); + .filter( + (el) => + el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }), + ) + .reduce<MediaTime | null>( + (bound, el) => + bound === null + ? el.startTime + : minMediaTime({ a: bound, b: el.startTime }), + null, + ); return [ { @@ -237,12 +263,18 @@ export class ResizeController { this.notify(); } - private snappedDelta(session: ResizeSession, rawDeltaTicks: number): number { + private snappedDelta({ + session, + rawDeltaTime, + }: { + session: ResizeSession; + rawDeltaTime: MediaTime; + }): MediaTime { const { snappingEnabled, isShiftHeld, zoomLevel } = this.config; if (!snappingEnabled || isShiftHeld()) { this.config.onSnapPointChange?.(null); - return rawDeltaTicks; + return rawDeltaTime; } const tracks = this.config.getSceneTracks(); @@ -264,15 +296,15 @@ export class ResizeController { let closestSnapPoint: SnapPoint | null = null; let closestSnapDistance = Infinity; - let deltaTicks = rawDeltaTicks; + let deltaTime = rawDeltaTime; for (const member of session.members) { const baseEdgeTime = session.side === "left" ? member.startTime - : member.startTime + member.duration; + : addMediaTime({ a: member.startTime, b: member.duration }); const snapResult = resolveTimelineSnap({ - targetTime: baseEdgeTime + rawDeltaTicks, + targetTime: addMediaTime({ a: baseEdgeTime, b: rawDeltaTime }), snapPoints, maxSnapDistance, }); @@ -282,28 +314,30 @@ export class ResizeController { ) { closestSnapDistance = snapResult.snapDistance; closestSnapPoint = snapResult.snapPoint; - deltaTicks = snapResult.snappedTime - baseEdgeTime; + deltaTime = subMediaTime({ a: snapResult.snappedTime, b: baseEdgeTime }); } } this.config.onSnapPointChange?.(closestSnapPoint); - return deltaTicks; + return deltaTime; } private handleMouseMove({ clientX }: MouseEvent): void { if (this.session.kind !== "active") return; const session = this.session; - const rawDeltaTicks = Math.round( - ((clientX - session.startX) / - (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) * - TICKS_PER_SECOND, - ); - const deltaTicks = this.snappedDelta(session, rawDeltaTicks); + const rawDeltaTime = mediaTime({ + ticks: Math.round( + ((clientX - session.startX) / + (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) * + TICKS_PER_SECOND, + ), + }); + const deltaTime = this.snappedDelta({ session, rawDeltaTime }); const result = computeGroupResize({ members: session.members, side: session.side, - deltaTicks, + deltaTime, fps: session.fps, }); diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts index f32c855e..67dc6e96 100644 --- a/apps/web/src/timeline/controllers/seek-controller.ts +++ b/apps/web/src/timeline/controllers/seek-controller.ts @@ -1,7 +1,7 @@ import type { MouseEvent as ReactMouseEvent } from "react"; -import { snappedSeekTime, type FrameRate } from "opencut-wasm"; +import type { FrameRate } from "opencut-wasm"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, snapSeekMediaTime, TICKS_PER_SECOND, type MediaTime } from "@/wasm"; type SeekSource = "ruler" | "tracks"; @@ -17,7 +17,7 @@ type Session = { kind: "idle" } | PendingSeekSession; export interface SeekConfig { zoomLevel: number; - duration: number; + duration: MediaTime; isSelecting: boolean; getPlayheadEl: () => HTMLDivElement | null; getTrackLabelsEl: () => HTMLDivElement | null; @@ -25,11 +25,11 @@ export interface SeekConfig { getTracksScrollEl: () => HTMLDivElement | null; getActiveProjectFps: () => FrameRate | null; clearSelectedElements: () => void; - seek: (time: number) => void; + seek: (time: MediaTime) => void; setTimelineViewState: (viewState: { zoomLevel: number; scrollLeft: number; - playheadTime: number; + playheadTime: MediaTime; }) => void; } @@ -46,8 +46,8 @@ function pixelToTime({ clientX: number; scrollContainer: HTMLDivElement; zoomLevel: number; - duration: number; -}): number { + duration: MediaTime; +}): MediaTime { const rect = scrollContainer.getBoundingClientRect(); const mouseX = clientX - rect.left; const scrollLeft = scrollContainer.scrollLeft; @@ -60,7 +60,7 @@ function pixelToTime({ ), ); - return Math.round(rawTimeSeconds * TICKS_PER_SECOND); + return mediaTime({ ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND) }); } function isClickGesture({ @@ -193,11 +193,11 @@ export class SeekController { const fps = this.config.getActiveProjectFps(); const time = fps != null - ? (snappedSeekTime({ + ? snapSeekMediaTime({ time: rawTime, duration: this.config.duration, - rate: fps, - }) ?? rawTime) + fps, + }) : rawTime; this.config.seek(time); diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts index 8e0af4fe..91147354 100644 --- a/apps/web/src/timeline/controllers/zoom-controller.ts +++ b/apps/web/src/timeline/controllers/zoom-controller.ts @@ -3,6 +3,7 @@ import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/i import { timelineTimeToPixels } from "@/timeline/pixel-utils"; import { TIMELINE_ZOOM_MAX } from "@/timeline/scale"; import { zoomToSlider } from "@/timeline/zoom-utils"; +import type { MediaTime } from "@/wasm"; type ZoomUpdater = number | ((prev: number) => number); @@ -11,12 +12,12 @@ export interface ZoomConfig { getContainerEl: () => HTMLDivElement | null; getTracksScrollEl: () => HTMLDivElement | null; getRulerScrollEl: () => HTMLDivElement | null; - getCurrentPlayheadTime: () => number; - seek: (time: number) => void; + getCurrentPlayheadTime: () => MediaTime; + seek: (time: MediaTime) => void; setTimelineViewState: (viewState: { zoomLevel: number; scrollLeft: number; - playheadTime: number; + playheadTime: MediaTime; }) => void; } @@ -24,7 +25,13 @@ export interface ZoomConfigRef { readonly current: ZoomConfig; } -function clampZoom(zoomLevel: number, minZoom: number): number { +function clampZoom({ + zoomLevel, + minZoom, +}: { + zoomLevel: number; + minZoom: number; +}): number { return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel)); } @@ -48,7 +55,7 @@ export class ZoomController { const minZoom = this.config.minZoom; this.zoomLevelValue = deps.initialZoom !== undefined - ? clampZoom(deps.initialZoom, minZoom) + ? clampZoom({ zoomLevel: deps.initialZoom, minZoom }) : minZoom; this.previousZoom = this.zoomLevelValue; this.hasInitialized = deps.initialZoom !== undefined; @@ -88,7 +95,10 @@ export class ZoomController { typeof zoomLevelOrUpdater === "function" ? zoomLevelOrUpdater(this.zoomLevelValue) : zoomLevelOrUpdater; - const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom); + const nextZoom = clampZoom({ + zoomLevel: nextZoomRaw, + minZoom: this.config.minZoom, + }); if (nextZoom === this.zoomLevelValue) return; this.zoomLevelValue = nextZoom; @@ -114,10 +124,16 @@ export class ZoomController { } } - reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void { + reconcileInitialAndMinZoom({ + minZoom, + initialZoom, + }: { + minZoom: number; + initialZoom?: number; + }): void { if (initialZoom !== undefined && !this.hasInitialized) { this.hasInitialized = true; - this.setZoomLevel(clampZoom(initialZoom, minZoom)); + this.setZoomLevel(clampZoom({ zoomLevel: initialZoom, minZoom })); return; } @@ -249,7 +265,7 @@ export class ZoomController { return () => observer.disconnect(); } - restoreInitialPlayheadIfNeeded(initialPlayheadTime?: number): void { + restoreInitialPlayheadIfNeeded(initialPlayheadTime?: MediaTime): void { if (initialPlayheadTime === undefined) return; if (this.hasRestoredPlayhead) return; diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts index 2dec3186..0f28af56 100644 --- a/apps/web/src/timeline/creation.ts +++ b/apps/web/src/timeline/creation.ts @@ -1,20 +1,17 @@ -import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { mediaTime, mediaTimeFromSeconds, TICKS_PER_SECOND } from "@/wasm"; -export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND; +export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({ + ticks: 5 * TICKS_PER_SECOND, +}); -/** - * Resolves the duration in ticks that a newly created timeline element - * should have, given an optional source duration in seconds. - * - * Falls back to {@link DEFAULT_NEW_ELEMENT_DURATION} when the source has - * no known duration (e.g. stickers, graphics, text, or media whose - * probe failed). - */ export function toElementDurationTicks({ seconds, }: { seconds: number | null | undefined; -}): number { - if (seconds == null) return DEFAULT_NEW_ELEMENT_DURATION; - return Math.round(seconds * TICKS_PER_SECOND); +}) { + if (seconds == null) { + return DEFAULT_NEW_ELEMENT_DURATION; + } + + return mediaTimeFromSeconds({ seconds }); } diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts index 06639e01..d32fb649 100644 --- a/apps/web/src/timeline/defaults.ts +++ b/apps/web/src/timeline/defaults.ts @@ -1,6 +1,7 @@ import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import type { TTimelineViewState } from "@/project/types"; import type { BlendMode, Transform } from "@/rendering"; +import { ZERO_MEDIA_TIME } from "@/wasm"; import type { TextElement } from "./types"; const defaultTransform: Transform = { @@ -30,32 +31,42 @@ const defaultTextBackground = { const defaultTextElement: Omit<TextElement, "id"> = { type: "text", name: "Text", - content: "Default text", - fontSize: 15, - fontFamily: "Arial", - color: "#ffffff", - background: { ...defaultTextBackground }, - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - letterSpacing: defaultTextLetterSpacing, - lineHeight: defaultTextLineHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - transform: { - ...defaultTransform, - position: { ...defaultTransform.position }, + startTime: ZERO_MEDIA_TIME, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { + content: "Default text", + fontSize: 15, + fontFamily: "Arial", + color: "#ffffff", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + letterSpacing: defaultTextLetterSpacing, + lineHeight: defaultTextLineHeight, + "background.enabled": defaultTextBackground.enabled, + "background.color": defaultTextBackground.color, + "background.cornerRadius": defaultTextBackground.cornerRadius, + "background.paddingX": defaultTextBackground.paddingX, + "background.paddingY": defaultTextBackground.paddingY, + "background.offsetX": defaultTextBackground.offsetX, + "background.offsetY": defaultTextBackground.offsetY, + "transform.positionX": defaultTransform.position.x, + "transform.positionY": defaultTransform.position.y, + "transform.scaleX": defaultTransform.scaleX, + "transform.scaleY": defaultTransform.scaleY, + "transform.rotate": defaultTransform.rotate, + opacity: defaultOpacity, + blendMode: defaultBlendMode, }, - opacity: defaultOpacity, }; const defaultTimelineViewState: TTimelineViewState = { zoomLevel: 1, scrollLeft: 0, - playheadTime: 0, + playheadTime: ZERO_MEDIA_TIME, }; export const DEFAULTS = { diff --git a/apps/web/src/timeline/drag-utils.ts b/apps/web/src/timeline/drag-utils.ts index a731d7a0..ad8d8001 100644 --- a/apps/web/src/timeline/drag-utils.ts +++ b/apps/web/src/timeline/drag-utils.ts @@ -1,5 +1,5 @@ import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; export function getMouseTimeFromClientX({ clientX, @@ -11,11 +11,13 @@ export function getMouseTimeFromClientX({ containerRect: DOMRect; zoomLevel: number; scrollLeft: number; -}): number { +}): MediaTime { const mouseX = clientX - containerRect.left + scrollLeft; const seconds = Math.max( 0, mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), ); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ + ticks: Math.round(seconds * TICKS_PER_SECOND), + }); } diff --git a/apps/web/src/timeline/element-snap-source.ts b/apps/web/src/timeline/element-snap-source.ts index ec1e5f65..26aa86f6 100644 --- a/apps/web/src/timeline/element-snap-source.ts +++ b/apps/web/src/timeline/element-snap-source.ts @@ -1,5 +1,6 @@ import type { SceneTracks } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import { addMediaTime } from "@/wasm"; export function getElementEdgeSnapPoints({ tracks, @@ -25,7 +26,7 @@ export function getElementEdgeSnapPoints({ trackId: track.id, }, { - time: element.startTime + element.duration, + time: addMediaTime({ a: element.startTime, b: element.duration }), type: "element-end", elementId: element.id, trackId: track.id, diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts index 676bc823..10c759d9 100644 --- a/apps/web/src/timeline/element-utils.ts +++ b/apps/web/src/timeline/element-utils.ts @@ -11,7 +11,6 @@ import { type CreateStickerElement, type CreateUploadAudioElement, type CreateLibraryAudioElement, - type TextBackground, type TextElement, type SceneTracks, type TimelineElement, @@ -28,7 +27,12 @@ import type { MediaType } from "@/media/types"; import { buildDefaultEffectInstance } from "@/effects"; import { buildDefaultGraphicInstance } from "@/graphics"; import type { ParamValues } from "@/params"; +import { + buildDefaultParamValues, + getBuiltInElementParams, +} from "@/params/registry"; import { capitalizeFirstLetter } from "@/utils/string"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export function canElementHaveAudio( element: TimelineElement, @@ -86,20 +90,12 @@ export function requiresMediaId({ ); } -function buildTextBackground( - raw: Partial<TextBackground> | undefined, -): TextBackground { - const color = raw?.color ?? DEFAULTS.text.element.background.color; - const enabled = raw?.enabled ?? color !== "transparent"; - return { - enabled, - color, - cornerRadius: raw?.cornerRadius, - paddingX: raw?.paddingX, - paddingY: raw?.paddingY, - offsetX: raw?.offsetX, - offsetY: raw?.offsetY, - }; +function buildDefaultElementParams({ + type, +}: { + type: TimelineElement["type"]; +}): ParamValues { + return buildDefaultParamValues(getBuiltInElementParams({ type })); } export function buildTextElement({ @@ -107,31 +103,21 @@ export function buildTextElement({ startTime, }: { raw: Partial<Omit<TextElement, "type" | "id">>; - startTime: number; + startTime: MediaTime; }): CreateTimelineElement { const t = raw as Partial<TextElement>; return { type: "text", name: t.name ?? DEFAULTS.text.element.name, - content: t.content ?? DEFAULTS.text.element.content, duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, - fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, - fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, - color: t.color ?? DEFAULTS.text.element.color, - background: buildTextBackground(t.background), - textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign, - fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight, - fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle, - textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration, - letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing, - lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight, - transform: t.transform ?? DEFAULTS.text.element.transform, - opacity: t.opacity ?? DEFAULTS.text.element.opacity, - blendMode: t.blendMode ?? DEFAULTS.element.blendMode, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { + ...buildDefaultElementParams({ type: "text" }), + ...(t.params ?? {}), + }, }; } @@ -141,8 +127,8 @@ export function buildEffectElement({ duration, }: { effectType: string; - startTime: number; - duration?: number; + startTime: MediaTime; + duration?: MediaTime; }): CreateEffectElement { const instance = buildDefaultEffectInstance({ effectType }); return { @@ -152,8 +138,8 @@ export function buildEffectElement({ params: instance.params, duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, }; } @@ -166,7 +152,7 @@ export function buildStickerElement({ }: { stickerId: string; name?: string; - startTime: number; + startTime: MediaTime; intrinsicWidth?: number; intrinsicHeight?: number; }): CreateStickerElement { @@ -180,14 +166,9 @@ export function buildStickerElement({ intrinsicHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: buildDefaultElementParams({ type: "sticker" }), }; } @@ -199,7 +180,7 @@ export function buildGraphicElement({ }: { definitionId: string; name?: string; - startTime: number; + startTime: MediaTime; params?: Partial<ParamValues>; }): CreateGraphicElement { const instance = buildDefaultGraphicInstance({ definitionId }); @@ -207,20 +188,36 @@ export function buildGraphicElement({ type: "graphic", name: name ?? capitalizeFirstLetter({ string: instance.definitionId }), definitionId: instance.definitionId, - params: { ...instance.params, ...(params ?? {}) } as ParamValues, + params: mergeParamValues({ + base: { + ...buildDefaultElementParams({ type: "graphic" }), + ...instance.params, + }, + overrides: params, + }), duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, }; } +function mergeParamValues({ + base, + overrides, +}: { + base: ParamValues; + overrides?: Partial<ParamValues>; +}): ParamValues { + const result: ParamValues = { ...base }; + for (const [key, value] of Object.entries(overrides ?? {})) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + function buildVideoElement({ mediaId, name, @@ -229,8 +226,8 @@ function buildVideoElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; }): CreateVideoElement { return { type: "video", @@ -238,19 +235,12 @@ function buildVideoElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, - muted: false, isSourceAudioEnabled: true, hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - volume: DEFAULTS.element.volume, + params: buildDefaultElementParams({ type: "video" }), }; } @@ -262,8 +252,8 @@ function buildImageElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; }): CreateImageElement { return { type: "image", @@ -271,15 +261,10 @@ function buildImageElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, + params: buildDefaultElementParams({ type: "image" }), }; } @@ -292,8 +277,8 @@ function buildUploadAudioElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateUploadAudioElement { const element: CreateUploadAudioElement = { @@ -303,11 +288,10 @@ function buildUploadAudioElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, + params: buildDefaultElementParams({ type: "audio" }), }; if (buffer) { element.buffer = buffer; @@ -326,8 +310,8 @@ export function buildElementFromMedia({ mediaId: string; mediaType: MediaType; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateTimelineElement { switch (mediaType) { @@ -355,8 +339,8 @@ export function buildLibraryAudioElement({ }: { sourceUrl: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateLibraryAudioElement { const element: CreateLibraryAudioElement = { @@ -366,11 +350,10 @@ export function buildLibraryAudioElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, + params: buildDefaultElementParams({ type: "audio" }), }; if (buffer) { element.buffer = buffer; @@ -410,8 +393,8 @@ export function getElementFontFamilies({ const families = new Set<string>(); for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { for (const element of track.elements) { - if (element.type === "text" && element.fontFamily) { - families.add(element.fontFamily); + if (element.type === "text" && typeof element.params.fontFamily === "string") { + families.add(element.params.fontFamily); } if ("masks" in element) { for (const mask of element.masks ?? []) { diff --git a/apps/web/src/timeline/group-move/build-group.ts b/apps/web/src/timeline/group-move/build-group.ts index 00f39d55..3e19e3e3 100644 --- a/apps/web/src/timeline/group-move/build-group.ts +++ b/apps/web/src/timeline/group-move/build-group.ts @@ -2,6 +2,7 @@ import type { ElementRef, SceneTracks } from "@/timeline"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; import type { GroupMember, MoveGroup } from "./types"; import { getTrackPlacementById } from "./track-placement"; +import { subMediaTime } from "@/wasm"; export function buildMoveGroup({ anchorRef, @@ -59,7 +60,10 @@ export function buildMoveGroup({ elementId: element.id, elementType: element.type, duration: element.duration, - timeOffset: element.startTime - anchorElement.startTime, + timeOffset: subMediaTime({ + a: element.startTime, + b: anchorElement.startTime, + }), trackSection: placement.section, sectionIndex: placement.sectionIndex, displayIndex: placement.displayIndex, diff --git a/apps/web/src/timeline/group-move/resolve-move.ts b/apps/web/src/timeline/group-move/resolve-move.ts index 60940315..8307aad0 100644 --- a/apps/web/src/timeline/group-move/resolve-move.ts +++ b/apps/web/src/timeline/group-move/resolve-move.ts @@ -12,6 +12,13 @@ import { getTrackPlacementByDisplayIndex, getTrackPlacementById, } from "./track-placement"; +import { + addMediaTime, + maxMediaTime, + type MediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; type GroupMoveTarget = | { @@ -32,7 +39,7 @@ export function resolveGroupMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; target: GroupMoveTarget; }): GroupMoveResult | null { if (target.kind === "newTracks") { @@ -61,7 +68,7 @@ function resolveExistingTrackMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; anchorTargetTrackId: string; }): GroupMoveResult | null { const anchorTargetPlacement = getTrackPlacementById({ @@ -93,7 +100,10 @@ function resolveExistingTrackMove({ targetTrackId: targetTrackIdsByElementId.get(member.elementId) ?? member.trackId, elementId: member.elementId, - newStartTime: clampedAnchorStartTime + member.timeOffset, + newStartTime: addMediaTime({ + a: clampedAnchorStartTime, + b: member.timeOffset, + }), })); if (!canApplyMovesToExistingTracks({ tracks, moves })) { @@ -119,7 +129,7 @@ function resolveNewTrackMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; anchorInsertIndex: number; newTrackIds: string[]; }): GroupMoveResult | null { @@ -173,7 +183,10 @@ function resolveNewTrackMove({ sourceTrackId: member.trackId, targetTrackId: newTrackIds[memberIndex], elementId: member.elementId, - newStartTime: clampedAnchorStartTime + member.timeOffset, + newStartTime: addMediaTime({ + a: clampedAnchorStartTime, + b: member.timeOffset, + }), })); return { @@ -335,17 +348,26 @@ function clampAnchorStartTime({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; targetTrackIdsByElementId: Map<string, string>; -}): number { - const minimumAnchorStartTime = Math.max( - 0, - ...group.members.map((member) => -member.timeOffset), - ); - let clampedAnchorStartTime = Math.max( - minimumAnchorStartTime, - anchorStartTime, +}): MediaTime { + const minimumAnchorStartTime = group.members.reduce( + (minimumStartTime, member) => + member.timeOffset < ZERO_MEDIA_TIME + ? maxMediaTime({ + a: minimumStartTime, + b: subMediaTime({ + a: ZERO_MEDIA_TIME, + b: member.timeOffset, + }), + }) + : minimumStartTime, + ZERO_MEDIA_TIME, ); + let clampedAnchorStartTime = + anchorStartTime < minimumAnchorStartTime + ? minimumAnchorStartTime + : anchorStartTime; const memberOnMainTrack = group.members.find( (member) => @@ -358,11 +380,13 @@ function clampAnchorStartTime({ const movingElementIds = new Set( group.members.map((member) => member.elementId), ); - const requestedMainStartTime = - clampedAnchorStartTime + memberOnMainTrack.timeOffset; + const requestedMainStartTime = addMediaTime({ + a: clampedAnchorStartTime, + b: memberOnMainTrack.timeOffset, + }); const earliestStationaryMainStartTime = tracks.main.elements .filter((element) => !movingElementIds.has(element.id)) - .reduce<number | null>((earliestStartTime, element) => { + .reduce<MediaTime | null>((earliestStartTime, element) => { if (earliestStartTime == null || element.startTime < earliestStartTime) { return element.startTime; } @@ -373,10 +397,13 @@ function clampAnchorStartTime({ earliestStationaryMainStartTime == null || requestedMainStartTime <= earliestStationaryMainStartTime ) { - clampedAnchorStartTime = Math.max( - minimumAnchorStartTime, - -memberOnMainTrack.timeOffset, - ); + clampedAnchorStartTime = maxMediaTime({ + a: minimumAnchorStartTime, + b: subMediaTime({ + a: ZERO_MEDIA_TIME, + b: memberOnMainTrack.timeOffset, + }), + }); } return clampedAnchorStartTime; @@ -422,7 +449,7 @@ function canApplyMovesToExistingTracks({ const sourceElement = sourceElements.get(move.elementId); return { startTime: move.newStartTime, - duration: sourceElement?.duration ?? 0, + duration: sourceElement?.duration ?? ZERO_MEDIA_TIME, }; }); if (hasOverlappingTimeSpans({ timeSpans })) { diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts index 7f431e6e..7aaf4423 100644 --- a/apps/web/src/timeline/group-move/snap.ts +++ b/apps/web/src/timeline/group-move/snap.ts @@ -9,6 +9,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/timeline/animation-snap-points"; import type { MoveGroup } from "./types"; +import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm"; export function snapGroupEdges({ group, @@ -18,12 +19,12 @@ export function snapGroupEdges({ zoomLevel, }: { group: MoveGroup; - anchorStartTime: number; + anchorStartTime: MediaTime; tracks: SceneTracks; - playheadTime: number; + playheadTime: MediaTime; zoomLevel: number; }): { - snappedAnchorStartTime: number; + snappedAnchorStartTime: MediaTime; snapPoint: SnapPoint | null; } { const excludeElementIds = new Set( @@ -47,7 +48,10 @@ export function snapGroupEdges({ let snapPoint: SnapPoint | null = null; for (const member of group.members) { - const memberStartTime = anchorStartTime + member.timeOffset; + const memberStartTime = addMediaTime({ + a: anchorStartTime, + b: member.timeOffset, + }); const memberStartSnap = resolveTimelineSnap({ targetTime: memberStartTime, snapPoints, @@ -58,12 +62,18 @@ export function snapGroupEdges({ memberStartSnap.snapDistance < closestSnapDistance ) { closestSnapDistance = memberStartSnap.snapDistance; - snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset; + snappedAnchorStartTime = subMediaTime({ + a: memberStartSnap.snappedTime, + b: member.timeOffset, + }); snapPoint = memberStartSnap.snapPoint; } const memberEndSnap = resolveTimelineSnap({ - targetTime: memberStartTime + member.duration, + targetTime: addMediaTime({ + a: memberStartTime, + b: member.duration, + }), snapPoints, maxSnapDistance, }); @@ -72,8 +82,13 @@ export function snapGroupEdges({ memberEndSnap.snapDistance < closestSnapDistance ) { closestSnapDistance = memberEndSnap.snapDistance; - snappedAnchorStartTime = - memberEndSnap.snappedTime - member.duration - member.timeOffset; + snappedAnchorStartTime = subMediaTime({ + a: subMediaTime({ + a: memberEndSnap.snappedTime, + b: member.duration, + }), + b: member.timeOffset, + }); snapPoint = memberEndSnap.snapPoint; } } diff --git a/apps/web/src/timeline/group-move/types.ts b/apps/web/src/timeline/group-move/types.ts index daf9c6e9..f97b698c 100644 --- a/apps/web/src/timeline/group-move/types.ts +++ b/apps/web/src/timeline/group-move/types.ts @@ -1,11 +1,12 @@ import type { ElementRef, ElementType, TrackType } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export type GroupTrackSection = "overlay" | "main" | "audio"; export interface GroupMember extends ElementRef { elementType: ElementType; - duration: number; - timeOffset: number; + duration: MediaTime; + timeOffset: MediaTime; trackSection: GroupTrackSection; sectionIndex: number; displayIndex: number; @@ -26,7 +27,7 @@ export interface PlannedElementMove { sourceTrackId: string; targetTrackId: string; elementId: string; - newStartTime: number; + newStartTime: MediaTime; } export interface GroupMoveResult { diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts index d22b6891..ed335997 100644 --- a/apps/web/src/timeline/group-resize/compute-resize.ts +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -1,9 +1,20 @@ -import { roundToFrame } from "opencut-wasm"; import { getSourceSpanAtClipTime, getTimelineDurationForSourceSpan, } from "@/retime"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + clampMediaTime, + maxMediaTime, + type MediaTime, + mediaTime, + minMediaTime, + roundFrameTicks, + roundMediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import type { ComputeGroupResizeArgs, GroupResizeMember, @@ -15,63 +26,88 @@ import type { export function computeGroupResize({ members, side, - deltaTicks, + deltaTime, fps, }: ComputeGroupResizeArgs): GroupResizeResult { - const minDuration = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); - const minimumDeltaTicks = Math.max( - ...members.map((member) => - getMinimumAllowedDeltaTicks({ - member, - side, - minDuration, - }), - ), - ); - const maximumDeltaTicks = Math.min( - ...members.map((member) => - getMaximumAllowedDeltaTicks({ + if (members.length === 0) { + return { deltaTime: ZERO_MEDIA_TIME, updates: [] }; + } + + const minDuration = mediaTime({ + ticks: Math.round((TICKS_PER_SECOND * fps.denominator) / fps.numerator), + }); + let minimumDeltaTime = getMinimumAllowedDeltaTime({ + member: members[0], + side, + minDuration, + }); + let maximumDeltaTime = getMaximumAllowedDeltaTime({ + member: members[0], + side, + minDuration, + }); + + for (const member of members.slice(1)) { + minimumDeltaTime = maxMediaTime({ + a: minimumDeltaTime, + b: getMinimumAllowedDeltaTime({ member, side, minDuration, }), - ), - ); - const clampedDeltaTicks = - minimumDeltaTicks > maximumDeltaTicks - ? minimumDeltaTicks - : Math.min(maximumDeltaTicks, Math.max(minimumDeltaTicks, deltaTicks)); + }); + const memberMaximum = getMaximumAllowedDeltaTime({ + member, + side, + minDuration, + }); + if (memberMaximum !== null) { + maximumDeltaTime = + maximumDeltaTime === null + ? memberMaximum + : minMediaTime({ a: maximumDeltaTime, b: memberMaximum }); + } + } + + const clampedDeltaTime = + maximumDeltaTime === null + ? maxMediaTime({ a: minimumDeltaTime, b: deltaTime }) + : clampMediaTime({ + time: deltaTime, + min: minimumDeltaTime, + max: maximumDeltaTime, + }); // Snap the drag delta to a frame exactly once, then derive every patch // field from that single snapped value. This keeps the invariant // `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same // delta is added on one side of the element and removed from the other, - // so the rounding cancels by construction. Rounding each field - // independently would break this — the individual rounds don't compose - // when `sourceDuration` isn't frame-aligned. - const snappedDeltaTicks = - roundToFrame({ time: clampedDeltaTicks, rate: fps }) ?? clampedDeltaTicks; + // so the rounding cancels by construction. Per-field rounding (the old + // approach) couldn't preserve this because the individual rounds don't + // compose when `sourceDuration` isn't frame-aligned. + const snappedDeltaTime = mediaTime({ + ticks: roundFrameTicks({ ticks: clampedDeltaTime, fps }), + }); // Re-clamp after rounding. Bounds derived from other elements are // frame-aligned, so this is normally a no-op; at the source-extent limit // the bound may not be frame-aligned, and honouring the bound takes // precedence over frame alignment (you can't extend past real content). - const finalDeltaTicks = - minimumDeltaTicks > maximumDeltaTicks - ? minimumDeltaTicks - : Math.min( - maximumDeltaTicks, - Math.max(minimumDeltaTicks, snappedDeltaTicks), - ); + const finalDeltaTime = + maximumDeltaTime === null + ? maxMediaTime({ a: minimumDeltaTime, b: snappedDeltaTime }) + : clampMediaTime({ + time: snappedDeltaTime, + min: minimumDeltaTime, + max: maximumDeltaTime, + }); return { - deltaTicks: Object.is(finalDeltaTicks, -0) ? 0 : finalDeltaTicks, + deltaTime: Object.is(finalDeltaTime, -0) ? ZERO_MEDIA_TIME : finalDeltaTime, updates: members.map((member) => buildResizeUpdate({ member, side, - deltaTicks: finalDeltaTicks, + deltaTime: finalDeltaTime, }), ), }; @@ -80,27 +116,30 @@ export function computeGroupResize({ function buildResizeUpdate({ member, side, - deltaTicks, + deltaTime, }: { member: GroupResizeMember; side: ResizeSide; - deltaTicks: number; + deltaTime: MediaTime; }): GroupResizeUpdate { const sourceDelta = getSourceDeltaForClipDelta({ member, - clipDelta: deltaTicks, + clipDelta: deltaTime, }); if (side === "left") { return { trackId: member.trackId, elementId: member.elementId, - patch: { - trimStart: Math.max(0, member.trimStart + sourceDelta), - trimEnd: member.trimEnd, - startTime: member.startTime + deltaTicks, - duration: member.duration - deltaTicks, - }, + patch: { + trimStart: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: member.trimStart, b: sourceDelta }), + }), + trimEnd: member.trimEnd, + startTime: addMediaTime({ a: member.startTime, b: deltaTime }), + duration: subMediaTime({ a: member.duration, b: deltaTime }), + }, }; } @@ -109,72 +148,95 @@ function buildResizeUpdate({ elementId: member.elementId, patch: { trimStart: member.trimStart, - trimEnd: Math.max(0, member.trimEnd - sourceDelta), + trimEnd: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ a: member.trimEnd, b: sourceDelta }), + }), startTime: member.startTime, - duration: member.duration + deltaTicks, + duration: addMediaTime({ a: member.duration, b: deltaTime }), }, }; } -function getMinimumAllowedDeltaTicks({ +function getMinimumAllowedDeltaTime({ member, side, minDuration, }: { member: GroupResizeMember; side: ResizeSide; - minDuration: number; -}): number { + minDuration: MediaTime; +}): MediaTime { if (side === "right") { - return minDuration - member.duration; + return subMediaTime({ a: minDuration, b: member.duration }); } - const leftNeighborFloor = Number.isFinite(member.leftNeighborBound) - ? member.leftNeighborBound - member.startTime - : -member.startTime; + const leftNeighborFloor = + member.leftNeighborBound !== null + ? subMediaTime({ a: member.leftNeighborBound, b: member.startTime }) + : subMediaTime({ a: ZERO_MEDIA_TIME, b: member.startTime }); if (member.sourceDuration == null) { return leftNeighborFloor; } - const maximumSourceExtension = - getDurationForVisibleSourceSpan({ + const maximumSourceExtension = subMediaTime({ + a: getDurationForVisibleSourceSpan({ member, - sourceSpan: - getVisibleSourceSpanForDuration({ + sourceSpan: addMediaTime({ + a: getVisibleSourceSpanForDuration({ member, duration: member.duration, - }) + member.trimStart, - }) - member.duration; - return Math.max(leftNeighborFloor, -maximumSourceExtension); + }), + b: member.trimStart, + }), + }), + b: member.duration, + }); + return maxMediaTime({ + a: leftNeighborFloor, + b: subMediaTime({ a: ZERO_MEDIA_TIME, b: maximumSourceExtension }), + }); } -function getMaximumAllowedDeltaTicks({ +function getMaximumAllowedDeltaTime({ member, side, minDuration, }: { member: GroupResizeMember; side: ResizeSide; - minDuration: number; -}): number { + minDuration: MediaTime; +}): MediaTime | null { if (side === "left") { - return member.duration - minDuration; + return subMediaTime({ a: member.duration, b: minDuration }); } - const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound) - ? member.rightNeighborBound - (member.startTime + member.duration) - : Infinity; + const rightNeighborCeiling = + member.rightNeighborBound === null + ? null + : subMediaTime({ + a: member.rightNeighborBound, + b: addMediaTime({ a: member.startTime, b: member.duration }), + }); if (member.sourceDuration == null) { return rightNeighborCeiling; } - const maximumVisibleSourceSpan = - getSourceDuration({ member }) - member.trimStart; + const maximumVisibleSourceSpan = subMediaTime({ + a: getSourceDuration({ member }), + b: member.trimStart, + }); const maximumDuration = getDurationForVisibleSourceSpan({ member, sourceSpan: maximumVisibleSourceSpan, }); - return Math.min(rightNeighborCeiling, maximumDuration - member.duration); + const sourceDurationCeiling = subMediaTime({ + a: maximumDuration, + b: member.duration, + }); + return rightNeighborCeiling === null + ? sourceDurationCeiling + : minMediaTime({ a: rightNeighborCeiling, b: sourceDurationCeiling }); } function getSourceDeltaForClipDelta({ @@ -182,21 +244,23 @@ function getSourceDeltaForClipDelta({ clipDelta, }: { member: GroupResizeMember; - clipDelta: number; -}): number { + clipDelta: MediaTime; +}): MediaTime { if (!member.retime) { return clipDelta; } - return clipDelta >= 0 - ? getSourceSpanAtClipTime({ - clipTime: clipDelta, - retime: member.retime, - }) - : -getSourceSpanAtClipTime({ - clipTime: Math.abs(clipDelta), - retime: member.retime, - }); + const sourceDelta = + clipDelta >= 0 + ? getSourceSpanAtClipTime({ + clipTime: clipDelta, + retime: member.retime, + }) + : -getSourceSpanAtClipTime({ + clipTime: Math.abs(clipDelta), + retime: member.retime, + }); + return roundMediaTime({ time: sourceDelta }); } function getVisibleSourceSpanForDuration({ @@ -204,15 +268,17 @@ function getVisibleSourceSpanForDuration({ duration, }: { member: GroupResizeMember; - duration: number; -}): number { + duration: MediaTime; +}): MediaTime { if (!member.retime) { return duration; } - return getSourceSpanAtClipTime({ - clipTime: duration, - retime: member.retime, + return roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: duration, + retime: member.retime, + }), }); } @@ -221,29 +287,33 @@ function getDurationForVisibleSourceSpan({ sourceSpan, }: { member: GroupResizeMember; - sourceSpan: number; -}): number { + sourceSpan: MediaTime; +}): MediaTime { if (!member.retime) { return sourceSpan; } - return getTimelineDurationForSourceSpan({ - sourceSpan, - retime: member.retime, + return roundMediaTime({ + time: getTimelineDurationForSourceSpan({ + sourceSpan, + retime: member.retime, + }), }); } -function getSourceDuration({ member }: { member: GroupResizeMember }): number { - if (typeof member.sourceDuration === "number") { +function getSourceDuration({ member }: { member: GroupResizeMember }): MediaTime { + if (member.sourceDuration != null) { return member.sourceDuration; } - return ( - member.trimStart + - getVisibleSourceSpanForDuration({ + return addMediaTime({ + a: addMediaTime({ + a: member.trimStart, + b: getVisibleSourceSpanForDuration({ member, duration: member.duration, - }) + - member.trimEnd - ); + }), + }), + b: member.trimEnd, + }); } diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts index 511358ba..5c0f77f6 100644 --- a/apps/web/src/timeline/group-resize/types.ts +++ b/apps/web/src/timeline/group-resize/types.ts @@ -1,36 +1,37 @@ import type { FrameRate } from "opencut-wasm"; import type { ElementRef, RetimeConfig } from "@/timeline/types"; +import type { MediaTime } from "@/wasm"; export type ResizeSide = "left" | "right"; export interface GroupResizeMember extends ElementRef { - startTime: number; - duration: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; + startTime: MediaTime; + duration: MediaTime; + trimStart: MediaTime; + trimEnd: MediaTime; + sourceDuration?: MediaTime; retime?: RetimeConfig; - leftNeighborBound: number; - rightNeighborBound: number; + leftNeighborBound: MediaTime | null; + rightNeighborBound: MediaTime | null; } export interface GroupResizeUpdate extends ElementRef { patch: { - trimStart: number; - trimEnd: number; - startTime: number; - duration: number; + trimStart: MediaTime; + trimEnd: MediaTime; + startTime: MediaTime; + duration: MediaTime; }; } export interface GroupResizeResult { - deltaTicks: number; + deltaTime: MediaTime; updates: GroupResizeUpdate[]; } export interface ComputeGroupResizeArgs { members: GroupResizeMember[]; side: ResizeSide; - deltaTicks: number; + deltaTime: MediaTime; fps: FrameRate; } diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts index 26a21f85..220f3fec 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef, type RefObject } from "react"; +import { useEffect, useReducer, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { registerCanceller } from "@/editor/cancel-interaction"; @@ -63,17 +64,10 @@ export function useElementInteraction({ onChange: onSnapPointChange, }, }; - - const depsRef = useRef<ElementInteractionDeps>(deps); - depsRef.current = deps; - - const controllerRef = useRef<ElementInteractionController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ElementInteractionController({ - depsRef: depsRef as ElementInteractionDepsRef, - }); - } - const controller = controllerRef.current; + const depsRef = useCommittedRef(deps) as ElementInteractionDepsRef; + const [controller] = useState( + () => new ElementInteractionController({ depsRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts index 5a1ff1f0..141bba8f 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useKeyframeSelection } from "./use-keyframe-selection"; import { registerCanceller } from "@/editor/cancel-interaction"; import { @@ -8,6 +9,7 @@ import { type KeyframeDragState, } from "@/timeline/controllers/keyframe-drag-controller"; import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export type { KeyframeDragState }; @@ -18,7 +20,7 @@ export function useKeyframeDrag({ }: { zoomLevel: number; element: TimelineElement; - displayedStartTime: number; + displayedStartTime: MediaTime; }) { const editor = useEditor(); const { @@ -40,18 +42,13 @@ export function useKeyframeDrag({ toggleKeyframeSelection, selectKeyframeRange, executeCommand: (command) => editor.command.execute({ command }), - seek: (args) => editor.playback.seek(args), + seek: ({ time }) => editor.playback.seek({ time }), getTotalDuration: () => editor.timeline.getTotalDuration(), }; - - const configRef = useRef<KeyframeDragConfig>(config); - configRef.current = config; - - const controllerRef = useRef<KeyframeDragController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new KeyframeDragController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState( + () => new KeyframeDragController({ configRef }), + ); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts index 2081b939..ac6d91eb 100644 --- a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts +++ b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react";
+import { useContainerSize } from "@/hooks/use-container-size";
import { timelineTimeToSnappedPixels } from "@/timeline";
import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/timeline/components/layout";
+import { useScrollPosition } from "./use-scroll-position";
interface UseSnapIndicatorPositionParams {
snapPoint: { time: number } | null;
zoomLevel: number;
@@ -20,24 +21,9 @@ export function useSnapIndicatorPosition({ timelineRef,
tracksScrollRef,
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
- const [scrollLeft, setScrollLeft] = useState(0);
-
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
-
- if (!tracksViewport) return;
-
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
-
- setScrollLeft(tracksViewport.scrollLeft);
-
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
-
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const { height: timelineHeight } = useContainerSize({ containerRef: timelineRef });
+ const { scrollLeft } = useScrollPosition({ scrollRef: tracksScrollRef });
+ const timelineContainerHeight = timelineHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
const timelinePosition = timelineTimeToSnappedPixels({
diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts index 4ec2b80b..c405819e 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef, type RefObject } from "react"; +import { useEffect, useReducer, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { DragDropController, type DragDropConfig, @@ -37,15 +38,8 @@ export function useTimelineDragDrop({ insertElement: (args) => editor.timeline.insertElement(args), addClipEffect: (args) => editor.timeline.addClipEffect(args), }; - - const configRef = useRef<DragDropConfig>(config); - configRef.current = config; - - const controllerRef = useRef<DragDropController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new DragDropController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new DragDropController({ configRef })); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 12bfe76a..9f10c4a5 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,5 +1,6 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; import { timelineTimeToPixels } from "@/timeline"; @@ -7,6 +8,7 @@ import { PlayheadController, type PlayheadConfig, } from "@/timeline/controllers/playhead-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelinePlayheadProps { zoomLevel: number; @@ -44,17 +46,17 @@ export function useTimelinePlayhead({ seek: (time) => editor.playback.seek({ time }), setScrubbing: (scrubbing) => editor.playback.setScrubbing({ isScrubbing: scrubbing }), - setTimelineViewState: (viewState) => - editor.project.setTimelineViewState({ viewState }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), }; - const configRef = useRef(config); - configRef.current = config; - - const ctrlRef = useRef<PlayheadController | null>(null); - if (!ctrlRef.current) { - ctrlRef.current = new PlayheadController({ configRef }); - } - const ctrl = ctrlRef.current; + const configRef = useCommittedRef(config); + const [ctrl] = useState(() => new PlayheadController({ configRef })); // Scroll → keep playhead position in sync with scroll offset. useEffect(() => { @@ -68,7 +70,7 @@ export function useTimelinePlayhead({ // Playback events → update playhead position and auto-scroll during playback. useEffect(() => { - const handler = (time: number) => ctrl.handlePlaybackUpdate(time); + const handler = (time: MediaTime) => ctrl.handlePlaybackUpdate(time); ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); const unsubscribeUpdate = editor.playback.onUpdate(handler); const unsubscribeSeek = editor.playback.onSeek(handler); diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index 46363fb6..6bd0c606 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -1,5 +1,6 @@ -import { useEffect, useReducer, useRef } from "react"; +import { useEffect, useReducer, useState } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useTimelineStore } from "@/timeline/timeline-store"; @@ -10,6 +11,7 @@ import { } from "@/timeline/controllers/resize-controller"; import type { ResizeSide } from "@/timeline/group-resize"; import type { SnapPoint } from "@/timeline/snapping"; +import type { TimelineElement } from "@/timeline"; export type { ResizeSide }; @@ -41,7 +43,7 @@ export function useTimelineResize({ updates: updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, - updates: patch, + updates: patch as Partial<TimelineElement>, })), }), commitElements: (updates) => @@ -49,20 +51,13 @@ export function useTimelineResize({ updates: updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, - patch, + patch: patch as Partial<TimelineElement>, })), }), onSnapPointChange, }; - - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<ResizeController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ResizeController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new ResizeController({ configRef })); const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index 56f68ed6..ea9b44a7 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -1,9 +1,11 @@ -import { useEffect, useRef, type RefObject } from "react"; +import { useEffect, useState, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { SeekController, type SeekConfig, } from "@/timeline/controllers/seek-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelineSeekProps { playheadRef: RefObject<HTMLDivElement | null>; @@ -11,10 +13,10 @@ interface UseTimelineSeekProps { rulerScrollRef: RefObject<HTMLDivElement | null>; tracksScrollRef: RefObject<HTMLDivElement | null>; zoomLevel: number; - duration: number; + duration: MediaTime; isSelecting: boolean; clearSelectedElements: () => void; - seek: (time: number) => void; + seek: (time: MediaTime) => void; } export function useTimelineSeek({ @@ -40,18 +42,17 @@ export function useTimelineSeek({ getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, clearSelectedElements, seek, - setTimelineViewState: (viewState) => - editor.project.setTimelineViewState({ viewState }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), }; - - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<SeekController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new SeekController({ configRef }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState(() => new SeekController({ configRef })); useEffect(() => () => controller.destroy(), [controller]); diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index a8825a37..60768963 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -4,21 +4,23 @@ import { useEffect, useLayoutEffect, useReducer, - useRef, + useState, } from "react"; import { useEditor } from "@/editor/use-editor"; +import { useCommittedRef } from "@/hooks/use-committed-ref"; import { TIMELINE_ZOOM_MIN } from "@/timeline/scale"; import { ZoomController, type ZoomConfig, } from "@/timeline/controllers/zoom-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelineZoomProps { containerRef: RefObject<HTMLDivElement | null>; minZoom?: number; initialZoom?: number; initialScrollLeft?: number; - initialPlayheadTime?: number; + initialPlayheadTime?: MediaTime; tracksScrollRef: RefObject<HTMLDivElement | null>; rulerScrollRef: RefObject<HTMLDivElement | null>; } @@ -47,24 +49,26 @@ export function useTimelineZoom({ getRulerScrollEl: () => rulerScrollRef.current, getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), seek: (time) => editor.playback.seek({ time }), - setTimelineViewState: (viewState) => - editor.project.setTimelineViewState({ viewState }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), }; - const configRef = useRef(config); - configRef.current = config; - - const controllerRef = useRef<ZoomController | null>(null); - if (!controllerRef.current) { - controllerRef.current = new ZoomController({ configRef, initialZoom }); - } - const controller = controllerRef.current; + const configRef = useCommittedRef(config); + const [controller] = useState( + () => new ZoomController({ configRef, initialZoom }), + ); const zoomLevel = controller.zoomLevel; const [, rerender] = useReducer((n: number) => n + 1, 0); useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - controller.reconcileInitialAndMinZoom(minZoom, initialZoom); + controller.reconcileInitialAndMinZoom({ minZoom, initialZoom }); }, [controller, minZoom, initialZoom]); useLayoutEffect(() => { diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts index 5ee079b1..21944561 100644 --- a/apps/web/src/timeline/index.ts +++ b/apps/web/src/timeline/index.ts @@ -1,29 +1,33 @@ -import type { SceneTracks } from "./types";
-
-export * from "./types";
-export * from "./drag";
-export * from "./track-capabilities";
-export * from "./track-element-update";
-export * from "./element-utils";
-export * from "./audio-separation";
-export * from "./zoom-utils";
-export * from "./ruler-utils";
-export * from "./pixel-utils";
-
-export function calculateTotalDuration({
- tracks,
-}: {
- tracks: SceneTracks;
-}): number {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- if (orderedTracks.length === 0) return 0;
-
- const trackEndTimes = orderedTracks.map((track) =>
- track.elements.reduce((maxEnd, element) => {
- const elementEnd = element.startTime + element.duration;
- return Math.max(maxEnd, elementEnd);
- }, 0),
- );
-
- return Math.max(...trackEndTimes, 0);
-}
+import { addMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +import type { SceneTracks } from "./types"; + +export * from "./types"; +export * from "./drag"; +export * from "./track-capabilities"; +export * from "./track-element-update"; +export * from "./element-utils"; +export * from "./audio-separation"; +export * from "./zoom-utils"; +export * from "./ruler-utils"; +export * from "./pixel-utils"; + +export function calculateTotalDuration({ + tracks, +}: { + tracks: SceneTracks; +}): MediaTime { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + if (orderedTracks.length === 0) return ZERO_MEDIA_TIME; + + let maxEnd: MediaTime = ZERO_MEDIA_TIME; + for (const track of orderedTracks) { + for (const element of track.elements) { + const elementEnd = addMediaTime({ + a: element.startTime, + b: element.duration, + }); + if (elementEnd > maxEnd) maxEnd = elementEnd; + } + } + return maxEnd; +} diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts index ebbfe578..202c9eb8 100644 --- a/apps/web/src/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts @@ -14,6 +14,7 @@ import type { } from "@/timeline"; import type { Transform } from "@/rendering"; import { resolveTrackPlacement } from "@/timeline/placement"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; function buildTransform(): Transform { return { @@ -67,11 +68,11 @@ function buildElement({ id, type: "audio", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, - volume: 1, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { volume: 1, muted: false }, sourceType: "upload", mediaId: `media-${id}`, } satisfies AudioElement; @@ -80,51 +81,66 @@ function buildElement({ id, type: "graphic", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, definitionId: `graphic-${id}`, - params: {}, - transform: buildTransform(), - opacity: 1, + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, } satisfies GraphicElement; case "text": return { id, type: "text", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, - content: id, - fontSize: 32, - fontFamily: "sans-serif", - color: "#ffffff", - background: { - enabled: false, - color: "#000000", + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { + content: id, + fontSize: 32, + fontFamily: "sans-serif", + color: "#ffffff", + "background.enabled": false, + "background.color": "#000000", + textAlign: "left", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, }, - textAlign: "left", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - transform: buildTransform(), - opacity: 1, } satisfies TextElement; case "video": return { id, type: "video", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, mediaId: `media-${id}`, - transform: buildTransform(), - opacity: 1, + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, } satisfies VideoElement; } @@ -224,7 +240,11 @@ function buildTimeSpan({ duration: number; excludeElementId?: string; }) { - return { startTime, duration, excludeElementId }; + return { + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + excludeElementId, + }; } function buildSceneTracks({ diff --git a/apps/web/src/timeline/placement/main-track.ts b/apps/web/src/timeline/placement/main-track.ts index ab2ab7bd..36632226 100644 --- a/apps/web/src/timeline/placement/main-track.ts +++ b/apps/web/src/timeline/placement/main-track.ts @@ -1,4 +1,5 @@ import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export const MAIN_TRACK_NAME = "Main Track"; @@ -31,9 +32,9 @@ export function enforceMainTrackStart({ }: { tracks: SceneTracks; targetTrackId: string; - requestedStartTime: number; + requestedStartTime: MediaTime; excludeElementId?: string; -}): number { +}): MediaTime { if (tracks.main.id !== targetTrackId) { return requestedStartTime; } @@ -43,11 +44,11 @@ export function enforceMainTrackStart({ excludeElementId, }); if (!earliestElement) { - return 0; + return ZERO_MEDIA_TIME; } if (requestedStartTime <= earliestElement.startTime) { - return 0; + return ZERO_MEDIA_TIME; } return requestedStartTime; diff --git a/apps/web/src/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts index 26275392..680cb0bb 100644 --- a/apps/web/src/timeline/placement/resolve.ts +++ b/apps/web/src/timeline/placement/resolve.ts @@ -13,6 +13,7 @@ import type { PlacementSubject, PlacementTimeSpan, } from "./types"; +import { ZERO_MEDIA_TIME } from "@/wasm"; type ResolveTrackPlacementParams = PlacementSubject & { tracks: SceneTracks; @@ -32,7 +33,7 @@ function buildExistingTrackResult({ timeSpans: PlacementTimeSpan[]; }): PlacementResult { const firstSpan = timeSpans[0]; - const requestedStartTime = firstSpan?.startTime ?? 0; + const requestedStartTime = firstSpan?.startTime ?? ZERO_MEDIA_TIME; const adjustedStartTime = enforceMainTrackStart({ tracks, targetTrackId: track.id, diff --git a/apps/web/src/timeline/placement/types.ts b/apps/web/src/timeline/placement/types.ts index 6847ab36..748f34d7 100644 --- a/apps/web/src/timeline/placement/types.ts +++ b/apps/web/src/timeline/placement/types.ts @@ -1,8 +1,9 @@ import type { ElementType, TrackType } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface PlacementTimeSpan { - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; excludeElementId?: string; } @@ -29,7 +30,7 @@ export type PlacementResult = trackId: string; trackIndex: number; trackType: TrackType; - adjustedStartTime?: number; + adjustedStartTime?: MediaTime; } | { kind: "newTrack"; diff --git a/apps/web/src/timeline/playhead-snap-source.ts b/apps/web/src/timeline/playhead-snap-source.ts index 88e21eba..0b41e17b 100644 --- a/apps/web/src/timeline/playhead-snap-source.ts +++ b/apps/web/src/timeline/playhead-snap-source.ts @@ -1,9 +1,10 @@ import type { SnapPoint } from "@/timeline/snapping"; +import type { MediaTime } from "@/wasm"; export function getPlayheadSnapPoints({ playheadTime, }: { - playheadTime: number; + playheadTime: MediaTime; }): SnapPoint[] { return [{ time: playheadTime, type: "playhead" }]; } diff --git a/apps/web/src/timeline/scenes.ts b/apps/web/src/timeline/scenes.ts index d48c943c..8aa1b6b5 100644 --- a/apps/web/src/timeline/scenes.ts +++ b/apps/web/src/timeline/scenes.ts @@ -2,6 +2,7 @@ import type { TScene } from "@/timeline"; import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/timeline";
import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null;
@@ -89,10 +90,10 @@ export function getProjectDurationFromScenes({ scenes,
}: {
scenes: TScene[];
-}): number {
+}): MediaTime {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
if (!mainScene?.tracks) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
return calculateTotalDuration({ tracks: mainScene.tracks });
diff --git a/apps/web/src/timeline/snapping/resolve.ts b/apps/web/src/timeline/snapping/resolve.ts index 2f00d081..dcbc670d 100644 --- a/apps/web/src/timeline/snapping/resolve.ts +++ b/apps/web/src/timeline/snapping/resolve.ts @@ -1,11 +1,12 @@ import type { SnapPoint, SnapResult } from "./types"; +import type { MediaTime } from "@/wasm"; export function resolveTimelineSnap({ targetTime, snapPoints, maxSnapDistance, }: { - targetTime: number; + targetTime: MediaTime; snapPoints: SnapPoint[]; maxSnapDistance: number; }): SnapResult { diff --git a/apps/web/src/timeline/snapping/types.ts b/apps/web/src/timeline/snapping/types.ts index 2a6d0903..7bffd84f 100644 --- a/apps/web/src/timeline/snapping/types.ts +++ b/apps/web/src/timeline/snapping/types.ts @@ -1,3 +1,5 @@ +import type { MediaTime } from "@/wasm"; + export type SnapPointType = | "element-start" | "element-end" @@ -6,14 +8,14 @@ export type SnapPointType = | "keyframe"; export interface SnapPoint { - time: number; + time: MediaTime; type: SnapPointType; elementId?: string; trackId?: string; } export interface SnapResult { - snappedTime: number; + snappedTime: MediaTime; snapPoint: SnapPoint | null; snapDistance: number; } diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index 338da9d9..5b3e4d29 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -2,7 +2,7 @@ import type { ElementAnimations } from "@/animation/types"; import type { Effect } from "@/effects/types"; import type { Mask } from "@/masks/types"; import type { ParamValues } from "@/params"; -import type { BlendMode, Transform } from "@/rendering"; +import type { MediaTime } from "@/wasm"; export type ElementRef = { trackId: string; @@ -10,10 +10,10 @@ export type ElementRef = { }; export interface Bookmark { - time: number; + time: MediaTime; note?: string; color?: string; - duration?: number; + duration?: MediaTime; } export interface TScene { @@ -86,8 +86,6 @@ export interface RetimeConfig { interface BaseAudioElement extends BaseTimelineElement { type: "audio"; - volume: number; - muted?: boolean; buffer?: AudioBuffer; retime?: RetimeConfig; } @@ -107,25 +105,21 @@ export type AudioElement = UploadAudioElement | LibraryAudioElement; interface BaseTimelineElement { id: string; name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; + duration: MediaTime; + startTime: MediaTime; + trimStart: MediaTime; + trimEnd: MediaTime; + sourceDuration?: MediaTime; animations?: ElementAnimations; + params: ParamValues; } export interface VideoElement extends BaseTimelineElement { type: "video"; mediaId: string; - volume?: number; - muted?: boolean; isSourceAudioEnabled?: boolean; hidden?: boolean; retime?: RetimeConfig; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; masks?: Mask[]; } @@ -134,40 +128,13 @@ export interface ImageElement extends BaseTimelineElement { type: "image"; mediaId: string; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; masks?: Mask[]; } -export interface TextBackground { - enabled: boolean; - color: string; - cornerRadius?: number; - paddingX?: number; - paddingY?: number; - offsetX?: number; - offsetY?: number; -} - export interface TextElement extends BaseTimelineElement { type: "text"; - content: string; - fontSize: number; - fontFamily: string; - color: string; - background: TextBackground; - textAlign: "left" | "center" | "right"; - fontWeight: "normal" | "bold"; - fontStyle: "normal" | "italic"; - textDecoration: "none" | "underline" | "line-through"; - letterSpacing?: number; - lineHeight?: number; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; } @@ -178,20 +145,13 @@ export interface StickerElement extends BaseTimelineElement { intrinsicWidth?: number; intrinsicHeight?: number; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; } export interface GraphicElement extends BaseTimelineElement { type: "graphic"; definitionId: string; - params: ParamValues; hidden?: boolean; - transform: Transform; - opacity: number; - blendMode?: BlendMode; effects?: Effect[]; masks?: Mask[]; } @@ -199,13 +159,9 @@ export interface GraphicElement extends BaseTimelineElement { export interface EffectElement extends BaseTimelineElement { type: "effect"; effectType: string; - params: ParamValues; } -export type ElementUpdatePatch = - | { transform: Transform } - | { opacity: number } - | { volume: number }; +export type ElementUpdatePatch = { params?: Partial<ParamValues> }; export type TimelineElement = | AudioElement @@ -269,18 +225,32 @@ export type CreateTimelineElement = | CreateGraphicElement | CreateEffectElement; +export interface ElementDragState { + isDragging: boolean; + elementId: string | null; + dragElementIds: string[]; + dragTimeOffsets: Record<string, MediaTime>; + trackId: string | null; + startMouseX: number; + startMouseY: number; + startElementTime: MediaTime; + clickOffsetTime: MediaTime; + currentTime: MediaTime; + currentMouseY: number; +} + export type ElementDragView = | { readonly kind: "idle" } | { readonly kind: "dragging"; readonly anchorElementId: string; readonly trackId: string; - readonly memberTimeOffsets: ReadonlyMap<string, number>; + readonly memberTimeOffsets: ReadonlyMap<string, MediaTime>; readonly startMouseX: number; readonly startMouseY: number; - readonly startElementTime: number; - readonly clickOffsetTime: number; - readonly currentTime: number; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; + readonly currentTime: MediaTime; readonly currentMouseX: number; readonly currentMouseY: number; readonly dropTarget: DropTarget | null; @@ -290,7 +260,7 @@ export interface DropTarget { trackIndex: number; isNewTrack: boolean; insertPosition: "above" | "below" | null; - xPosition: number; + xPosition: MediaTime; targetElement: { elementId: string; trackId: string } | null; } @@ -299,13 +269,13 @@ export interface ComputeDropTargetParams { mouseX: number; mouseY: number; tracks: SceneTracks; - playheadTime: number; + playheadTime: MediaTime; isExternalDrop: boolean; - elementDuration: number; + elementDuration: MediaTime; pixelsPerSecond: number; zoomLevel: number; verticalDragDirection?: "up" | "down" | null; - startTimeOverride?: number; + startTimeOverride?: MediaTime; excludeElementId?: string; targetElementTypes?: string[]; } diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts index beacf897..590a8f34 100644 --- a/apps/web/src/timeline/update-pipeline.ts +++ b/apps/web/src/timeline/update-pipeline.ts @@ -6,6 +6,7 @@ import { } from "@/retime"; import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline"; import { isRetimableElement } from "@/timeline"; +import { ZERO_MEDIA_TIME, roundMediaTime } from "@/wasm"; type ElementUpdateField = keyof TimelineElement | string; @@ -61,9 +62,11 @@ const deriveRules: ElementUpdateRule[] = [ 0, sourceDuration - element.trimStart - element.trimEnd, ); - const nextDuration = getTimelineDurationForSourceSpan({ - sourceSpan: visibleSourceSpan, - retime: nextRetime, + const nextDuration = roundMediaTime({ + time: getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }), }); return { @@ -94,7 +97,10 @@ const enforceRules: ElementUpdateRule[] = [ { triggers: ["startTime"], apply: ({ element, context }) => { - const requestedStartTime = Math.max(0, element.startTime); + const requestedStartTime = + element.startTime < ZERO_MEDIA_TIME + ? ZERO_MEDIA_TIME + : element.startTime; if (context.trackId !== context.tracks.main.id) { return { element: { @@ -118,7 +124,7 @@ const enforceRules: ElementUpdateRule[] = [ ...element, startTime: !earliestElement || requestedStartTime <= earliestElement.startTime - ? 0 + ? ZERO_MEDIA_TIME : requestedStartTime, }, }; @@ -135,7 +141,14 @@ export function applyElementUpdate({ patch: Partial<TimelineElement>; context: ElementUpdateContext; }): TimelineElement { - let nextElement = { ...element, ...patch } as TimelineElement; + let nextElement = { + ...element, + ...patch, + params: { + ...element.params, + ...(patch.params ?? {}), + }, + } as TimelineElement; const changedFields = new Set( Object.keys(patch) as ElementUpdateField[], ); diff --git a/apps/web/src/utils/geometry.ts b/apps/web/src/utils/geometry.ts index 23fc4f46..b5c50584 100644 --- a/apps/web/src/utils/geometry.ts +++ b/apps/web/src/utils/geometry.ts @@ -5,8 +5,14 @@ export function dimensionToAspectRatio({ width: number; height: number; }): string { - const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b)); - const divisor = gcd(width, height); + const gcd = ({ + a, + b, + }: { + a: number; + b: number; + }): number => (b === 0 ? a : gcd({ a: b, b: a % b })); + const divisor = gcd({ a: width, b: height }); const aspectWidth = width / divisor; const aspectHeight = height / divisor; return `${aspectWidth}:${aspectHeight}`; diff --git a/apps/web/src/wasm/index.ts b/apps/web/src/wasm/index.ts index 60031a0e..c979b620 100644 --- a/apps/web/src/wasm/index.ts +++ b/apps/web/src/wasm/index.ts @@ -1 +1 @@ -export * from "./ticks"; +export * from "./media-time"; diff --git a/apps/web/src/wasm/media-time.ts b/apps/web/src/wasm/media-time.ts new file mode 100644 index 00000000..0ec23cf3 --- /dev/null +++ b/apps/web/src/wasm/media-time.ts @@ -0,0 +1,232 @@ +import { + lastFrameTime as _lastFrameTime, + parseTimecode as _parseTimecode, + roundToFrame as _roundToFrame, + snappedSeekTime as _snappedSeekTime, + TICKS_PER_SECOND as _TICKS_PER_SECOND, + mediaTimeFromSeconds as _mediaTimeFromSeconds, + mediaTimeToSeconds as _mediaTimeToSeconds, + type FrameRate, + type TimeCodeFormat, +} from "opencut-wasm"; + +/** + * Integer-tick time. Mirrors `MediaTime(i64)` in `rust/crates/time/src/media_time.rs`. + * + * `opencut-wasm` exposes `MediaTime` as a bare `number` alias because tsify + * collapses tuple structs. The brand here is the TS-side discipline that + * recovers the invariant: a `MediaTime` is an integer count of ticks, and the + * only legal way to construct one from a fractional `number` is `roundMediaTime` + * (or `mediaTimeFromSeconds`, which rounds inside the wasm boundary). + * + * Reading is free — `MediaTime` is assignable to `number`. Writing is gated — + * a bare `number` is not assignable to `MediaTime`. + */ +export type MediaTime = number & { readonly __mediaTime: unique symbol }; + +export const TICKS_PER_SECOND = _TICKS_PER_SECOND(); + +function isMediaTime(value: number): value is MediaTime { + return Number.isInteger(value); +} + +function requireMediaTime({ + value, + context, +}: { + value: number; + context: string; +}): MediaTime { + if (!isMediaTime(value)) { + throw new Error(`${context}: expected an integer tick count, got ${value}`); + } + return value; +} + +export const ZERO_MEDIA_TIME = requireMediaTime({ + value: 0, + context: "ZERO_MEDIA_TIME", +}); + +/** + * Construct a `MediaTime` from a known-integer tick count. Use `roundMediaTime` + * when the input may be fractional. + */ +export function mediaTime({ ticks }: { ticks: number }): MediaTime { + return requireMediaTime({ + value: ticks, + context: "mediaTime()", + }); +} + +/** + * Project a fractional value onto the integer-tick lattice. + * + * Rounds half away from zero (`-1.5 → -2`, `1.5 → 2`) and normalises `-0` to + * `0`. The away-from-zero rule matches Rust's `.round()` and avoids the + * `Math.round(-0.5) === -0` quirk that propagates `-0` into stored data. + */ +export function roundMediaTime({ time }: { time: number }): MediaTime { + const roundedMagnitude = Math.round(Math.abs(time)); + if (roundedMagnitude === 0) { + return ZERO_MEDIA_TIME; + } + return requireMediaTime({ + value: time < 0 ? -roundedMagnitude : roundedMagnitude, + context: "roundMediaTime()", + }); +} + +export function mediaTimeFromSeconds({ + seconds, +}: { + seconds: number; +}): MediaTime { + const result = _mediaTimeFromSeconds({ seconds }); + if (result === undefined) { + throw new Error( + `mediaTimeFromSeconds: rust returned undefined for seconds=${seconds}`, + ); + } + return requireMediaTime({ + value: result, + context: "mediaTimeFromSeconds()", + }); +} + +export function mediaTimeToSeconds({ time }: { time: MediaTime }): number { + return _mediaTimeToSeconds({ time }); +} + +/** + * Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too. + */ +export function addMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return requireMediaTime({ + value: a + b, + context: "addMediaTime()", + }); +} + +export function subMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return requireMediaTime({ + value: a - b, + context: "subMediaTime()", + }); +} + +export function maxMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return a > b ? a : b; +} + +export function minMediaTime({ + a, + b, +}: { + a: MediaTime; + b: MediaTime; +}): MediaTime { + return a < b ? a : b; +} + +export function clampMediaTime({ + time, + min, + max, +}: { + time: MediaTime; + min: MediaTime; + max: MediaTime; +}): MediaTime { + if (time < min) return min; + if (time > max) return max; + return time; +} + +export function roundFrameTime({ + time, + fps, +}: { + time: MediaTime; + fps: FrameRate; +}): MediaTime { + return requireMediaTime({ + value: _roundToFrame({ time, rate: fps }) ?? time, + context: "roundFrameTime()", + }); +} + +export function roundFrameTicks({ + ticks, + fps, +}: { + ticks: number; + fps: FrameRate; +}): number { + return _roundToFrame({ time: ticks, rate: fps }) ?? ticks; +} + +export function snapSeekMediaTime({ + time, + duration, + fps, +}: { + time: MediaTime; + duration: MediaTime; + fps: FrameRate; +}): MediaTime { + return requireMediaTime({ + value: _snappedSeekTime({ time, duration, rate: fps }) ?? time, + context: "snapSeekMediaTime()", + }); +} + +export function lastFrameMediaTime({ + duration, + fps, +}: { + duration: MediaTime; + fps: FrameRate; +}): MediaTime { + return requireMediaTime({ + value: _lastFrameTime({ duration, rate: fps }) ?? duration, + context: "lastFrameMediaTime()", + }); +} + +export function parseMediaTimecode({ + timeCode, + format, + fps, +}: { + timeCode: string; + format: TimeCodeFormat; + fps: FrameRate; +}): MediaTime | null { + const parsedTime = _parseTimecode({ timeCode, format, rate: fps }); + if (parsedTime == null) { + return null; + } + return requireMediaTime({ + value: parsedTime, + context: "parseMediaTimecode()", + }); +} diff --git a/apps/web/src/wasm/ticks.ts b/apps/web/src/wasm/ticks.ts deleted file mode 100644 index ababe6b3..00000000 --- a/apps/web/src/wasm/ticks.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm"; - -export const TICKS_PER_SECOND = _TICKS_PER_SECOND(); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3ac10911..3573338a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,39 +1,39 @@ -{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./src/*"],
- "content-collections": ["./.content-collections/generated"]
- },
- "forceConsistentCasingInFileNames": true
- },
- "include": [
- "**/*.ts",
- "**/*.tsx",
- "apps/web/.next/types/**/*.ts",
- "next-env.d.ts",
- ".next/types/**/*.ts",
- "src/types/**/*.d.ts",
- "../../use-frame-cache.ts",
- "src/stores/timeline-store.ts",
- ".next/dev/types/**/*.ts"
- ],
- "exclude": ["node_modules"]
-}
+{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"], + "content-collections": ["./.content-collections/generated"] + }, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "apps/web/.next/types/**/*.ts", + "next-env.d.ts", + ".next/types/**/*.ts", + "src/types/**/*.d.ts", + "../../use-frame-cache.ts", + "src/stores/timeline-store.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} @@ -1,19 +1,30 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "opencut", "dependencies": { - "@biomejs/biome": "^2.4.8", + "opencut": ".", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "better-auth": "^1.4.15", "next": "^16.1.3", + "opencut-wasm": "^0.2.10", }, "devDependencies": { + "@eslint/js": "^9.39.4", + "@next/eslint-plugin-next": "^16.2.4", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.5.0", + "prettier": "^3.8.3", "turbo": "^2.8.20", "typescript": "^6.0.2", + "typescript-eslint": "^8.59.0", }, }, "apps/web": { @@ -54,7 +65,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.2.8", + "opencut-wasm": "^0.2.10", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", @@ -153,63 +164,63 @@ "@aws-sdk/client-sqs": ["@aws-sdk/client-sqs@3.984.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.6", "@aws-sdk/credential-provider-node": "^3.972.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-sdk-sqs": "^3.972.5", "@aws-sdk/middleware-user-agent": "^3.972.6", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.984.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.4", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/md5-js": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-TDvHpOUWlpanc3xQ5Xw0y8L2hoojBFCCSmXQ/6rKqGOf1ScX3dMA+K9aF0Zp0iwjhSh4VvsHD42esl8XwQZDjA=="], - "@aws-sdk/core": ["@aws-sdk/core@3.973.24", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.15", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.19", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-lMPlYlYfQdNZhlkJgnkmESwrY+hNh3PljmZ+37oAqLNdJ6rnILAwFSyc6B3bJeDOtMORNnMQIej0aTRuOlDyhQ=="], - "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.5", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg=="], + "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.7", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-cXp0VTDWT76p3hyK5D51yIKEfpf6/zsUvMfaB8CkyqadJxMQ8SbEeVroregmDlZbtG31wkj9ei0WnftmieggLg=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-X/yGB73LmDW/6MdDJGCDzZBUXnM3ys4vs9l+5ZTJmiEswDdP1OjeoAFlFjVGS9o4KB2wZWQ9KOfdVNSSK6Ep3w=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-h694K7+tRuepSRJr09wTvQfaEnjzsKZ5s7fbESrVds02GT/QzViJ94/HCNwM7bUfFxqpPXHxulZfL6Cou0dwPg=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.33", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" } }, "sha512-c0ZF+lwoWVvX5iCaGKL5T/4DnIw88CGqxA0BcBs3U86mIp5EZYPVg+KSPkMXOyokmADvNewiMUfSG2uFwjRp0g=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/credential-provider-env": "^3.972.22", "@aws-sdk/credential-provider-http": "^3.972.24", "@aws-sdk/credential-provider-login": "^3.972.24", "@aws-sdk/credential-provider-process": "^3.972.22", "@aws-sdk/credential-provider-sso": "^3.972.24", "@aws-sdk/credential-provider-web-identity": "^3.972.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-O46fFmv0RDFWiWEA9/e6oW92BnsyAXuEgTTasxHligjn2RCr9L/DK773m/NoFaL3ZdNAUz8WxgxunleMnHAkeQ=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.35", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/credential-provider-env": "^3.972.31", "@aws-sdk/credential-provider-http": "^3.972.33", "@aws-sdk/credential-provider-login": "^3.972.35", "@aws-sdk/credential-provider-process": "^3.972.31", "@aws-sdk/credential-provider-sso": "^3.972.35", "@aws-sdk/credential-provider-web-identity": "^3.972.35", "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-jsU4u/cRkKFLKQS0k918FQ27fzXLG5ENiLWQMYE6581zLeI2hWh04ptlrvZMB3wJT/5d+vSzJk74X1CMFr4y8Q=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-sIk8oa6AzDoUhxsR11svZESqvzGuXesw62Rl2oW6wguZx8i9cdGCvkFg+h5K7iucUZP8wyWibUbJMc+J66cu5g=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.35", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-5oa3j0cA50jPqgNhZ9XdJVopuzUf1klRb28/2MfLYWWiPi9DRVvbrBWT+DidbHTT36520VuXZJahQwR+YgSjrg=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.25", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.22", "@aws-sdk/credential-provider-http": "^3.972.24", "@aws-sdk/credential-provider-ini": "^3.972.24", "@aws-sdk/credential-provider-process": "^3.972.22", "@aws-sdk/credential-provider-sso": "^3.972.24", "@aws-sdk/credential-provider-web-identity": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-m7dR0Dsva2P+VUpL+VkC0WwiDby5pgmWXkRVDB5rlwv0jXJrQJf7YMtCoM8Wjk0H9jPeCYOxOXXcIgp/qp5Alg=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.36", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.31", "@aws-sdk/credential-provider-http": "^3.972.33", "@aws-sdk/credential-provider-ini": "^3.972.35", "@aws-sdk/credential-provider-process": "^3.972.31", "@aws-sdk/credential-provider-sso": "^3.972.35", "@aws-sdk/credential-provider-web-identity": "^3.972.35", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-4nT2T8Z7vH8KE9EdjEsuIlHpZSlcaK2PrKbQBjuUGU46BCCzF3WvP0u0Uiosni3Ykmmn4rWLVawoOCLotUtCbg=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Os32s8/4gTZjBk5BtoS/cuTILaj+K72d0dVG7TCJX/fC4598cxwLDmf1AEHEpER5oL3K//yETjvFaz0V8oO5Xw=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-eKeT4MXumpBJsrDLCYcSzIkFPVTFn/es7It2oogp2OhU/ic7P/+xzFpQx9ZhwtXS57Mc5S42BPWi7lHmvs/nYg=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/token-providers": "3.1015.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-PaFv7snEfypU2yXkpvfyWgddEbDLtgVe51wdZlinhc2doubBjUzJZZpgwuF2Jenl1FBydMhNpMjD6SBUM3qdSA=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.35", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/token-providers": "3.1036.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-bCuBdfnj0KGDMdLp6utMTLiJcFN2ek9EgZinxQZZSc3FxjJ/HSqeqab2cjbnoNfy8RM6suDCsRkmVY1izp9I+A=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-J6H4R1nvr3uBTqD/EeIPAskrBtET4WFfNhpFySr2xW7bVZOXpQfPjrLSIx65jcNjBmLXzWq8QFLdVoGxiGG/SA=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.35", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-swW6Bwvl8lanyEMtZOWE/oR6yqcRQH4HTQZUVsnDVgoXvRjRywpYpLv2BWwjUFyjPrqsdX6FeTkf4tMSe/qFTQ=="], - "@aws-sdk/dynamodb-codec": ["@aws-sdk/dynamodb-codec@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@smithy/core": "^3.23.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-AfkNIk19MOeGXfRyznRMZNOTUt79HU2pl7GP606oUIGELmS495xfvGDGp9nGYbOPxWhXfM7/s51V8+lvmCEwVg=="], + "@aws-sdk/dynamodb-codec": ["@aws-sdk/dynamodb-codec@3.973.5", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@smithy/core": "^3.23.17", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-SNF2VCwK2MA9P3jmz/DW498BK0SkGMyLBeAe+ZJy2fVoEOHq0b/ZdRNw4fQMuq07gdNxLErY9RnXUo6Rdu2QIA=="], - "@aws-sdk/endpoint-cache": ["@aws-sdk/endpoint-cache@3.972.4", "", { "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" } }, "sha512-GdASDnWanLnHxKK0hqV97xz23QmfA/C8yGe0PiuEmWiHSe+x+x+mFEj4sXqx9IbfyPncWz8f4EhNwBSG9cgYCg=="], + "@aws-sdk/endpoint-cache": ["@aws-sdk/endpoint-cache@3.972.5", "", { "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" } }, "sha512-itVdge0NozgtgmtbZ25FVwWU3vGlE7x7feE/aOEJNkQfEpbkrF8Rj1QmnK+2blFfYE1xWt/iU+6/jUp/pv1+MA=="], - "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw=="], + "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA=="], - "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.972.8", "", { "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.4", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-S0oXx1QbSpMDBMJn4P0hOxW8ieGAdRT+G9NbL+ESWkkoCGf9D++fKYD2fyBGtIy88OrP7wgECpXgGLAcGpIj0A=="], + "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.972.11", "", { "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.5", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-vXARCZVFQHdsd6qPPZyC/hh+5x2XsCYKqUQDCqnUlpGpChMpDojOOacQWdLJ+FFXKN8X3cmLOGrtgx/zysCKqQ=="], - "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ=="], + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ=="], - "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.4", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.24", "@aws-sdk/crc64-nvme": "^3.972.5", "@aws-sdk/types": "^3.973.6", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fhCbZXPAyy8btnNbnBlR7Cc1nD54cETSvGn2wey71ehsM89AKPO8Dpco9DBAAgvrUdLrdHQepBXcyX4vxC5OwA=="], + "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.5", "@aws-sdk/crc64-nvme": "^3.972.7", "@aws-sdk/types": "^3.973.8", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-b6QUe2hQX9XsnCzp6mtzVaERhganDKeb8lmGL6pVhr7rRVH9S9keDFW7uKytuuqmcY5943FixoGqn/QL+sbUBA=="], - "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="], + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg=="], - "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw=="], + "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ=="], - "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="], + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ=="], - "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA=="], + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ=="], - "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4sXxVC/enYgMkZefNMOzU6C6KtAXEvwVJLgNcUx1dvROH6GvKB5Sm2RGnGzTp0/PwkibIyMw4kOzF8tbLfaBAQ=="], + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/UL96JKjsjdodcRRMKl99tLQvK6Oi9ptLC9iU1yiTF/ruaDX0mtBBtnLNZDxIZRJOCVOtB49ed1YaTadqygk8Q=="], - "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-LnzPRRoDXGtlFV2G1p2rsY6fRKrbf6Pvvc21KliSLw3+NmQca2+Aa1QIMRbpQvZYedsSqkGYwxe+qvXwQ2uxDw=="], + "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.22", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-DtR3mEiOUJcnEX/QuXmvbJto6xvQzp2ftnHb29c0aQYdmmzbKf0gsu9ovx1i/yy4ZR6m0rttTucS0iiP32dlGA=="], - "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw=="], + "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-QxiMPofvOt8SwSynTOmuZfvvPM1S9QfkESBxB22NMHTRXCJhR5BygLl8IXfC4jELiisQgwsgUby21GtXfX3f/g=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.35", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-retry": "^4.3.4", "tslib": "^2.6.2" } }, "sha512-hOFWNOjVmOocpRlrU04nYxjMOeoe0Obu5AXEuhB8zblMCPl3cG1hdluQCZERRKFyhMQjwZnDbhSHjoMUjetFGw=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.14", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.24", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.11", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-fSESKvh1VbfjtV3QMnRkCPZWkUbQof6T/DOpiLp33yP2wA+rbwwnZeG3XT3Ekljgw2I8X4XaQPnw+zSR8yxJ5Q=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.3", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.5", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/signature-v4-multi-region": "^3.996.22", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-SivE6GP228IVgfsrr2c/vqTg95X0Qj39Yw4uIrcddpkUzIltNMoNOR62leHOLhODfjv9K8X2mPTwS69A5kT0nQ=="], - "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eQ+dFU05ZRC/lC2XpYlYSPlXtX3VT8sn5toxN2Fv7EXlMoA2p9V7vUBKqHunfD4TRLpxUq8Y8Ol/nCqiv327Ng=="], + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.13", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/config-resolver": "^4.4.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A=="], "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.984.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.6", "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-TaWbfYCwnuOSvDSrgs7QgoaoXse49E7LzUkVOUhoezwB7bkmhp+iojADm7UepCEu4021SquD7NG1xA+WCvmldA=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1015.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.24", "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-3OSD4y110nisRhHzFOjoEeHU4GQL4KpzkX9PxzWaiZe0Yg2+thZKM0Pn9DjYwezH5JYfh/K++xK/SE0IHGrmCQ=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1036.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.5", "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-aNSJ6jjDYayxN9ZA1JpycVScX93Lx03kKZ1EXt3DGOTahcWVLJj3oLAlop0xKP+vP2Ga2t49p1tEaMkTbCCaZA=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="], + "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="], @@ -217,57 +228,81 @@ "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], - "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="], + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.11", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-1qdXbXo2s5MMLpUvw00284LsbhtlQ4ul7Zzdn5n+7p4WVgCMLqhxImpHIrjSoc72E/fyc4Wq8dLtUld2Gsh+lA=="], + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.21", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Av4UHTcAWgdvbN0IP9pbtf4Qa1+6LtJqQdZWj5pLn5J67w0pnJJAZZ+7JPPcj2KN3378zD2JDM9DwJKEyvyMTQ=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.15", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.19", "", { "dependencies": { "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.1", "tslib": "^2.6.2" } }, "sha512-Cw8IOMdBUEIl8ZlhRC3Dc/E64D5B5/8JhV6vhPLiPfJwcRC84S6F8aBOIi/N4vR9ZyA4I5Cc0Ateb/9EHaJXeQ=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], - "@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@better-auth/core": ["@better-auth/core@1.4.15", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-uAvq8YA7SaS7v+TrvH/Kwt7LAJihzUqB3FX8VweDsqu3gn5t51M+Bve+V1vVWR9qBAtC6cN68V6b+scxZxDY4A=="], + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.4.15", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.15" } }, "sha512-7NW/2PS4RN85rv+ozpAezP/kSLPZeWkxqcA6RA/CFXqWp2YR2e5q5E6Hym1qBgVBkoAQa3lWFdX3b+jEs+vvrQ=="], + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="], + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="], + "@better-auth/core": ["@better-auth/core@1.6.9", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "kysely": "^0.28.14" }, "optionalPeers": ["kysely"] }, "sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="], + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0" } }, "sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="], + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-h+AiRJ/TsBSi+ZDjySASBpbJ/9QCXBre34PSKgCz7QmTHrFM9Cg2EM4AM7LjR5lPXipEE+2rWPBc9wfnUBjhcw=="], + + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A=="], + + "@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], - "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260317.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260317.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260317.1", "", { "os": "linux", "cpu": "x64" }, "sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260424.1", "", { "os": "linux", "cpu": "x64" }, "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260317.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260424.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260317.1", "", { "os": "win32", "cpu": "x64" }, "sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260424.1", "", { "os": "win32", "cpu": "x64" }, "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg=="], - "@content-collections/core": ["@content-collections/core@0.14.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "camelcase": "^8.0.0", "chokidar": "^4.0.3", "esbuild": "^0.25.11", "gray-matter": "^4.0.3", "p-limit": "^6.1.0", "picomatch": "^4.0.2", "pluralize": "^8.0.0", "serialize-javascript": "^6.0.2", "tinyglobby": "^0.2.5", "yaml": "^2.4.5" }, "peerDependencies": { "typescript": "^5.0.2" } }, "sha512-mfviKnONzVcr1D3uasZFEk0E9N4a7liCra06imQrmKF07nGDuvrRKUFoUwUc2IGrW3EYqKF/KV06zfJRzNe3sg=="], + "@content-collections/core": ["@content-collections/core@0.14.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "camelcase": "^8.0.0", "chokidar": "^4.0.3", "esbuild": "^0.25.11", "gray-matter": "^4.0.3", "p-limit": "^6.1.0", "picomatch": "^4.0.2", "pluralize": "^8.0.0", "serialize-javascript": "^7.0.3", "tinyglobby": "^0.2.5", "yaml": "^2.4.5" }, "peerDependencies": { "typescript": "^5.0.2" } }, "sha512-nbgfRswxudlKDBEO2AXVGGfsFMlenV2iVjNDddQGWD0qk3lBDkTRJy0AqdBvBOccB8/cj95nJyXEVocRRNyUcA=="], "@content-collections/integrations": ["@content-collections/integrations@0.5.0", "", { "peerDependencies": { "@content-collections/core": "0.x" } }, "sha512-1en7r518sct0Y8CQ5IsuuBN4uAmtNLaWuxmseW43OxeXyj43Uu2aPBfbopjL4b5xH8WZBdDrrPmikgOl42U46A=="], @@ -279,9 +314,9 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], + "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], - "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -339,13 +374,31 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@floating-ui/core": ["@floating-ui/core@1.7.2", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], - "@floating-ui/dom": ["@floating-ui/dom@1.7.2", "", { "dependencies": { "@floating-ui/core": "^1.7.2", "@floating-ui/utils": "^0.2.10" } }, "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA=="], + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.4", "", { "dependencies": { "@floating-ui/dom": "^1.7.2" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw=="], + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], - "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], "@hello-pangea/dnd": ["@hello-pangea/dnd@18.0.1", "", { "dependencies": { "@babel/runtime": "^7.26.7", "css-box-model": "^1.2.1", "raf-schd": "^4.0.3", "react-redux": "^9.2.0", "redux": "^5.0.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ=="], @@ -353,11 +406,21 @@ "@hugeicons/react": ["@hugeicons/react@1.1.6", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-c2LhXJMAW5wN1pC/smBXG0YPqUON6ceR/ZdXHCjEI9KvB+hjtqYjmzIxok5hAQOeXGz0WtORgCQMzqewFKAZwg=="], - "@huggingface/jinja": ["@huggingface/jinja@0.5.3", "", {}, "sha512-asqfZ4GQS0hD876Uw4qiUb7Tr/V5Q+JZuo2L+BtdrD4U40QU58nIRq3ZSgAzJgT874VLjhGVacaYfrdpXtEvtA=="], + "@huggingface/jinja": ["@huggingface/jinja@0.5.8", "", {}, "sha512-ZdElB7DPS7QQS8ZnFc5RPPtkg+eN11z8AmIZWAyes6pSbwXqiFB/POVevvm01begdSX1ho9Gxln/F6qlQMsuaA=="], "@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="], - "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -411,7 +474,7 @@ "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -421,55 +484,59 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="], - "@napi-rs/canvas": ["@napi-rs/canvas@0.1.92", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.92", "@napi-rs/canvas-darwin-arm64": "0.1.92", "@napi-rs/canvas-darwin-x64": "0.1.92", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.92", "@napi-rs/canvas-linux-arm64-gnu": "0.1.92", "@napi-rs/canvas-linux-arm64-musl": "0.1.92", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.92", "@napi-rs/canvas-linux-x64-gnu": "0.1.92", "@napi-rs/canvas-linux-x64-musl": "0.1.92", "@napi-rs/canvas-win32-arm64-msvc": "0.1.92", "@napi-rs/canvas-win32-x64-msvc": "0.1.92" } }, "sha512-q7ZaUCJkEU5BeOdE7fBx1XWRd2T5Ady65nxq4brMf5L4cE1VV/ACq5w9Z5b/IVJs8CwSSIwc30nlthH0gFo4Ig=="], + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.100", "", { "os": "android", "cpu": "arm64" }, "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ=="], - "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.92", "", { "os": "android", "cpu": "arm64" }, "sha512-rDOtq53ujfOuevD5taxAuIFALuf1QsQWZe1yS/N4MtT+tNiDBEdjufvQRPWZ11FubL2uwgP8ApYU3YOaNu1ZsQ=="], + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.100", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A=="], - "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.92", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4PT6GRGCr7yMRehp42x0LJb1V0IEy1cDZDDayv7eKbFUIGbPFkV7CRC9Bee5MPkjg1EB4ZPXXUyy3gjQm7mR8Q=="], + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.100", "", { "os": "darwin", "cpu": "x64" }, "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw=="], - "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.92", "", { "os": "darwin", "cpu": "x64" }, "sha512-5e/3ZapP7CqPtDcZPtmowCsjoyQwuNMMD7c0GKPtZQ8pgQhLkeq/3fmk0HqNSD1i227FyJN/9pDrhw/UMTkaWA=="], + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.100", "", { "os": "linux", "cpu": "arm" }, "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA=="], - "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.92", "", { "os": "linux", "cpu": "arm" }, "sha512-j6KaLL9iir68lwpzzY+aBGag1PZp3+gJE2mQ3ar4VJVmyLRVOh+1qsdNK1gfWoAVy5w6U7OEYFrLzN2vOFUSng=="], + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw=="], - "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.92", "", { "os": "linux", "cpu": "arm64" }, "sha512-s3NlnJMHOSotUYVoTCoC1OcomaChFdKmZg0VsHFeIkeHbwX0uPHP4eCX1irjSfMykyvsGHTQDfBAtGYuqxCxhQ=="], + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ=="], - "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.92", "", { "os": "linux", "cpu": "arm64" }, "sha512-xV0GQnukYq5qY+ebkAwHjnP2OrSGBxS3vSi1zQNQj0bkXU6Ou+Tw7JjCM7pZcQ28MUyEBS1yKfo7rc7ip2IPFQ=="], + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.100", "", { "os": "linux", "cpu": "none" }, "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw=="], - "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.92", "", { "os": "linux", "cpu": "none" }, "sha512-+GKvIFbQ74eB/TopEdH6XIXcvOGcuKvCITLGXy7WLJAyNp3Kdn1ncjxg91ihatBaPR+t63QOE99yHuIWn3UQ9w=="], + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg=="], - "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.92", "", { "os": "linux", "cpu": "x64" }, "sha512-tFd6MwbEhZ1g64iVY2asV+dOJC+GT3Yd6UH4G3Hp0/VHQ6qikB+nvXEULskFYZ0+wFqlGPtXjG1Jmv7sJy+3Ww=="], + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA=="], - "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.92", "", { "os": "linux", "cpu": "x64" }, "sha512-uSuqeSveB/ZGd72VfNbHCSXO9sArpZTvznMVsb42nqPP7gBGEH6NJQ0+hmF+w24unEmxBhPYakP/Wiosm16KkA=="], + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.100", "", { "os": "win32", "cpu": "arm64" }, "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw=="], - "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.92", "", { "os": "win32", "cpu": "arm64" }, "sha512-20SK5AU/OUNz9ZuoAPj5ekWai45EIBDh/XsdrVZ8le/pJVlhjFU3olbumSQUXRFn7lBRS+qwM8kA//uLaDx6iQ=="], + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], - "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.92", "", { "os": "win32", "cpu": "x64" }, "sha512-KEhyZLzq1MXCNlXybz4k25MJmHFp+uK1SIb8yJB0xfrQjz5aogAMhyseSzewo+XxAq3OAOdyKvfHGNzT3w1RPg=="], + "@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="], - "@next/env": ["@next/env@16.1.3", "", {}, "sha512-BLP14oBOvZWXgfdJf9ao+VD8O30uE+x7PaV++QtACLX329WcRSJRO5YJ+Bcvu0Q+c/lei41TjSiFf6pXqnpbQA=="], + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.4", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CpOD3lmig6VflihVoGxiR/l5Jkjfi4uLaOR4ziriMv0YMDoF6cclI+p5t2nstM8TmaFiY6PCTBgRWB57/+LiBA=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-aF4us2JXh0zn3hNxvL1Bx3BOuh8Lcw3p3Xnurlvca/iptrDH1BrpObwkw9WZra7L7/0qB9kjlREq3hN/4x4x+Q=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8VRkcpcfBtYvhGgXAF7U3MBx6+G1lACM1XCo1JyaUr4KmAkTNP8Dv2wdMq7BI+jqRBw3zQE7c57+lmp7jCFfKA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-UbFx69E2UP7MhzogJRMFvV9KdEn4sLGPicClwgqnLht2TEi204B71HuVfps3ymGAh0c44QRAF+ZmvZZhLLmhNg=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SzGTfTjR5e9T+sZh5zXqG/oeRQufExxBF6MssXS7HPeZFE98JDhCRZXpSyCfWrWrYrzmnw/RVhlP2AxQm+wkRQ=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-HlrDpj0v+JBIvQex1mXHq93Mht5qQmfyci+ZNwGClnAQldSfxI6h0Vupte1dSR4ueNv4q7qp5kTnmLOBIQnGow=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3gFCp83/LSduZMSIa+lBREP7+5e7FxpdBoc9QrCdmp+dapmTK9I+SLpY60Z39GDmTXSZA4huGg9WwmYbr6+WRw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-1SZVfFT8zmMB+Oblrh5OKDvUo5mYQOkX2We6VGzpg7JUVZlqe4DYOFGKYZKTweSx1gbMixyO1jnFT4thU+nNHQ=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="], - "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], + "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], - "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], "@node-minify/core": ["@node-minify/core@8.0.6", "", { "dependencies": { "@node-minify/utils": "8.0.6", "glob": "9.3.5", "mkdirp": "1.0.4" } }, "sha512-/vxN46ieWDLU67CmgbArEvOb41zlYFOkOtr9QW9CnTrBLuTyGgkyNWC2y5+khvRw3Br58p2B5ZVSx/PxCTru6g=="], @@ -477,11 +544,19 @@ "@node-minify/utils": ["@node-minify/utils@8.0.6", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-csY4qcR7jUwiZmkreNTJhcypQfts2aY2CK+a+rXgXUImZiZiySh0FvwHjRnlqWKvg+y6ae9lHFzDRjBTmqlTIQ=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@opencut/web": ["@opencut/web@workspace:apps/web"], - "@opennextjs/aws": ["@opennextjs/aws@3.9.16", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q=="], + "@opennextjs/aws": ["@opennextjs/aws@3.10.4", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.15 <16 || >=16.2.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-xVmWHGdptJgVhQivuoeAYqsWpIgGoDEeZJC6AYMgvQYisDicGuS7gh10Z8MEmrgsJxNGdZ0arCCDieGxM88afw=="], - "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.18.0", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.16", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-JM236YHnKzroFAZqst1t28ZGOShvnkVUDtjrp7TJ/W2P3RLo4b6npJ8VEXOn6frs6lsUfR5rvsKYLYb7h1GIJQ=="], + "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.19.4", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.10.4", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.15 <16 || >=16.2.3", "wrangler": "^4.84.1" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-sRJmLFz6DqGzoToSTPyyQhxozXGgF/YsnHpgiCe2XTugfZZnfetMoI4BolFP65XAzIV3tdSSzCb+etK5s8UEUg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], @@ -631,75 +706,73 @@ "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - "@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="], - "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="], "@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="], - "@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="], + "@smithy/config-resolver": ["@smithy/config-resolver@4.4.17", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ=="], - "@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="], + "@smithy/core": ["@smithy/core@3.23.17", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ=="], - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.14", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg=="], - "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="], + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], - "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="], + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.14", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ=="], - "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="], + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA=="], - "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="], + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.14", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw=="], - "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="], + "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.14", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg=="], - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.17", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw=="], - "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="], + "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.15", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA=="], - "@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="], + "@smithy/hash-node": ["@smithy/hash-node@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g=="], - "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="], + "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ=="], - "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="], + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw=="], "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], - "@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="], + "@smithy/md5-js": ["@smithy/md5-js@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA=="], - "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="], + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.14", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw=="], - "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="], + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.32", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/middleware-serde": "^4.2.20", "@smithy/node-config-provider": "^4.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q=="], - "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="], + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.5.5", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/service-error-classification": "^4.3.0", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.4", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-wnYOpB5vATFKWrY2Z9Alb0KhjZI6AbzU6Fbz3Hq2GnURdRYWB4q+qWivQtSTwXcmWUA3MZ6krfwL6Cq5MAbxsA=="], - "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="], + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.20", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ=="], - "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="], + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA=="], - "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="], + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.14", "", { "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.6.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg=="], - "@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="], + "@smithy/property-provider": ["@smithy/property-provider@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ=="], - "@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="], + "@smithy/protocol-http": ["@smithy/protocol-http@5.3.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ=="], - "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="], + "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A=="], - "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="], + "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw=="], - "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="], + "@smithy/service-error-classification": ["@smithy/service-error-classification@4.3.0", "", { "dependencies": { "@smithy/types": "^4.14.1" } }, "sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A=="], - "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="], + "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.9", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ=="], - "@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="], + "@smithy/signature-v4": ["@smithy/signature-v4@5.3.14", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA=="], - "@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="], + "@smithy/smithy-client": ["@smithy/smithy-client@4.12.13", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-stack": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" } }, "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA=="], - "@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="], + "@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="], - "@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="], + "@smithy/url-parser": ["@smithy/url-parser@4.2.14", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ=="], "@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="], @@ -711,25 +784,25 @@ "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="], - "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="], + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.49", "", { "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw=="], - "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="], + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.54", "", { "dependencies": { "@smithy/config-resolver": "^4.4.17", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw=="], - "@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="], + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.4.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg=="], "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], - "@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="], + "@smithy/util-middleware": ["@smithy/util-middleware@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw=="], - "@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="], + "@smithy/util-retry": ["@smithy/util-retry@4.3.4", "", { "dependencies": { "@smithy/service-error-classification": "^4.3.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-FY1UQQ1VFmMwiYp1GVS4MeaGD5O0blLNYK0xCRHU+mJgeoH/hSY8Ld8sJWKQ6uznkh14HveRGQJncgPyNl9J+A=="], - "@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="], + "@smithy/util-stream": ["@smithy/util-stream@4.5.25", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA=="], "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], - "@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="], + "@smithy/util-waiter": ["@smithy/util-waiter@4.2.16", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-GtclrKoZ3Lt7jPQ7aTIYKfjY92OgceScftVnkTsG8e1KV8rkvZgN+ny6YSRhd9hxB8rZtwVbmln7NTvE5O3GmQ=="], "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], @@ -739,57 +812,57 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.4", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "postcss": "^8.5.6", "tailwindcss": "4.2.4" } }, "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg=="], "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], "@tsconfig/node18": ["@tsconfig/node18@1.0.3", "", {}, "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.8.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw=="], + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="], - "@turbo/linux-64": ["@turbo/linux-64@2.8.20", "", { "os": "linux", "cpu": "x64" }, "sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA=="], + "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="], - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA=="], + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="], - "@turbo/windows-64": ["@turbo/windows-64@2.8.20", "", { "os": "win32", "cpu": "x64" }, "sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw=="], + "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="], - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="], + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="], - "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="], - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="], @@ -801,17 +874,19 @@ "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], - "@types/pg": ["@types/pg@8.15.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg=="], + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], - "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -819,13 +894,33 @@ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@upstash/core-analytics": ["@upstash/core-analytics@0.0.10", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="], - "@upstash/ratelimit": ["@upstash/ratelimit@2.0.6", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-Uak5qklMfzFN5RXltxY6IXRENu+Hgmo9iEgMPOlUs2etSQas2N+hJfbHw37OUy4vldLRXeD0OzL+YRvO2l5acg=="], + "@upstash/ratelimit": ["@upstash/ratelimit@2.0.8", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w=="], - "@upstash/redis": ["@upstash/redis@1.35.4", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-WE1ZnhFyBiIjTDW13GbO6JjkiMVVjw5VsvS8ENmvvJsze/caMQ5paxVD44+U68IUVmkXcbsLSoE+VIYsHtbQEw=="], + "@upstash/redis": ["@upstash/redis@1.37.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -833,33 +928,63 @@ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "axe-core": ["axe-core@4.11.3", "", {}, "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], - "better-auth": ["better-auth@1.4.15", "", { "dependencies": { "@better-auth/core": "1.4.15", "@better-auth/telemetry": "1.4.15", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.12" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-XZr4GnFPbjvf8wip8AAjTrpGNn3Sba600zT+DgsR3NNCMWCt9aD8+nuRah6BHwHWnVP1nfnby07tPmti72SRBw=="], + "better-auth": ["better-auth@1.6.9", "", { "dependencies": { "@better-auth/core": "1.6.9", "@better-auth/drizzle-adapter": "1.6.9", "@better-auth/kysely-adapter": "1.6.9", "@better-auth/memory-adapter": "1.6.9", "@better-auth/mongo-adapter": "1.6.9", "@better-auth/prisma-adapter": "1.6.9", "@better-auth/telemetry": "1.6.9", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.14", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA=="], - "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + "better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], @@ -871,25 +996,33 @@ "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], - "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], + "caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -903,6 +1036,8 @@ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], @@ -915,6 +1050,10 @@ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -923,10 +1062,14 @@ "comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="], - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -943,17 +1086,27 @@ "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="], + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="], - "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], - "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], @@ -969,11 +1122,13 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], - "drizzle-orm": ["drizzle-orm@0.44.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-8nIiYQxOpgUicEL04YFojJmvC4DNO4KoyXsEIqN44+g6gNBr6hmVpWk3uyAt4CaTiRGDwoU+alfqNNeonLAFOQ=="], + "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -983,17 +1138,19 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], @@ -1001,35 +1158,65 @@ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-iterator-helpers": ["es-iterator-helpers@1.3.2", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -1039,18 +1226,40 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], - "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="], + "fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="], + + "fast-xml-parser": ["fast-xml-parser@5.7.1", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "feed": ["feed@5.1.0", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-qGNhgYygnefSkAHHrNHqC7p3R8J0/xQDS/cYUud8er/qD9EFGWyCdUDfULHTJQN1d3H3WprzVwMc9MfB4J50Wg=="], + "feed": ["feed@5.2.1", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-jTynzYPWs9ALjro0GW8j7sv9y7cJBeOdD4Y88kVqYy/eyusIX3g+499JiTDIlD9Ge/unebx57T4Uzo6vpYvMtA=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -1061,7 +1270,7 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - "framer-motion": ["framer-motion@12.23.6", "", { "dependencies": { "motion-dom": "^12.23.6", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -1071,6 +1280,14 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], @@ -1083,14 +1300,20 @@ "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "glob": ["glob@12.0.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + "globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="], + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -1103,13 +1326,19 @@ "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], @@ -1133,6 +1362,10 @@ "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], @@ -1147,73 +1380,157 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - "kysely": ["kysely@0.28.9", "", {}, "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA=="], + "kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="], - "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -1221,7 +1538,7 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], @@ -1231,7 +1548,7 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], @@ -1241,7 +1558,7 @@ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], - "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], @@ -1249,12 +1566,14 @@ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "mediabunny": ["mediabunny@1.29.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-RrlKs69MxRGa/l9cMGeI4hzuTSgchOGFHk9lIAuu9EcbSdJ05gDbRsTY67dojAiyUP3ic4PExij5OqDxqSv82Q=="], + "mediabunny": ["mediabunny@1.41.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-cJWBHvAyRNgTbsx8Z2oHptX/PdiywwsS+GIvv2k9eioXSOzzAemBsHwf7jM6fvW3b65azvZGP9RLuO1DI8JmdA=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1297,54 +1616,72 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "miniflare": ["miniflare@4.20260317.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260317.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-qNL+yWAFMX6fr0pWU6Lx1vNpPobpnDSF1V8eunIckWvoIQl8y1oBjL2RJFEGY3un+l3f9gwW9dirDPP26usYJQ=="], + "miniflare": ["miniflare@4.20260424.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw=="], - "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], "mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="], - "motion": ["motion@12.23.6", "", { "dependencies": { "framer-motion": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-6U55IW5i6Vut2ryKEhrZKg55490k9d6qdGXZoNSf98oQgDj5D7bqTnVJotQ6UW3AS6QfbW6KSLa7/e1gy+a07g=="], + "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], - "motion-dom": ["motion-dom@12.23.6", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w=="], + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], - "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="], + "nanoid": ["nanoid@5.1.9", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw=="], + + "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], - "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "next": ["next@16.1.3", "", { "dependencies": { "@next/env": "16.1.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.3", "@next/swc-darwin-x64": "16.1.3", "@next/swc-linux-arm64-gnu": "16.1.3", "@next/swc-linux-arm64-musl": "16.1.3", "@next/swc-linux-x64-gnu": "16.1.3", "@next/swc-linux-x64-musl": "16.1.3", "@next/swc-win32-arm64-msvc": "16.1.3", "@next/swc-win32-x64-msvc": "16.1.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw=="], + "next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "obliterator": ["obliterator@1.6.1", "", {}, "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -1359,39 +1696,53 @@ "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="], - "opencut-wasm": ["opencut-wasm@0.2.8", "", {}, "sha512-R1cXB4HuTC5RdbGRYD0Q2n9rWQWDRB7KMwGJnbjrhnnwxBbR5XDu/3tL7hlzb/RZ7VZcaX5OGHZ1QgL6ex/2cQ=="], + "opencut": ["opencut@root:", {}], + + "opencut-wasm": ["opencut-wasm@0.2.10", "", {}, "sha512-dy+Z9SWwpjLjgmTAMQoMMIUmdbUk9OXoWXLoacl9xT9TCrekIGSeMM0F7bJ1H3VJwwUxbGasaVibvn5AfmeZrg=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], + "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], - "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="], + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], - "pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="], + "pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="], "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], - "pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="], + "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="], - "pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="], + "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], @@ -1399,59 +1750,71 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], - "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], - "postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "protobufjs": ["protobufjs@7.5.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], - "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + + "react-hook-form": ["react-hook-form@7.74.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g=="], - "react-hook-form": ["react-hook-form@7.60.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A=="], + "react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="], - "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], - "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], @@ -1465,6 +1828,10 @@ "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "rehype-autolink-headings": ["rehype-autolink-headings@7.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw=="], "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], @@ -1479,25 +1846,37 @@ "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -1505,11 +1884,17 @@ "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], - "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "serialize-javascript": ["serialize-javascript@7.0.5", "", {}, "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -1521,7 +1906,7 @@ "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], @@ -1547,8 +1932,22 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1557,31 +1956,37 @@ "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - "strnum": ["strnum@2.2.2", "", {}, "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="], + "strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="], - "style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], - "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + "tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], "terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -1591,40 +1996,62 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "ts-tqdm": ["ts-tqdm@0.8.6", "", {}, "sha512-3X3M1PZcHtgQbnwizL+xU8CAgbYbeLHrrDwL9xxcZZrV5J+e7loJm1XrXozHjSkl44J0Zg0SgA8rXbh83kCkcQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "turbo": ["turbo@2.8.20", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.20", "@turbo/darwin-arm64": "2.8.20", "@turbo/linux-64": "2.8.20", "@turbo/linux-arm64": "2.8.20", "@turbo/windows-64": "2.8.20", "@turbo/windows-arm64": "2.8.20" }, "bin": { "turbo": "bin/turbo" } }, "sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ=="], + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "typescript-eslint": ["typescript-eslint@8.59.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], + "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], - "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], - "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], - "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -1633,7 +2060,7 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], - "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -1643,9 +2070,9 @@ "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], - "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="], + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "wavesurfer.js": ["wavesurfer.js@7.10.0", "", {}, "sha512-GiyAHdorqGtUYG5fe4BfTf5lmtSLhrXoHeNlMsR80JOiOZxOrIOv9QaIR8RnqlleJ6D8R9cqvZKR9lfJcWcapg=="], + "wavesurfer.js": ["wavesurfer.js@7.12.6", "", {}, "sha512-zSxPgOFprtyJ31ppHQF0+E9jAmjAhi1rR36yIW6h1GOYdpRxDe6mbkYtlChqLK0Iz8ROBweiEFw2zus7tDFibA=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], @@ -1657,9 +2084,19 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "workerd": ["workerd@1.20260317.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260317.1", "@cloudflare/workerd-darwin-arm64": "1.20260317.1", "@cloudflare/workerd-linux-64": "1.20260317.1", "@cloudflare/workerd-linux-arm64": "1.20260317.1", "@cloudflare/workerd-windows-64": "1.20260317.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g=="], + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], - "wrangler": ["wrangler@4.77.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260317.2", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260317.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260317.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-E2Gm69+K++BFd3QvoWjC290RPQj1vDOUotA++sNHmtKPb7EP6C8Qv+1D5Ii73tfZtyNgakpqHlh8lBBbVWTKAQ=="], + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], + + "wrangler": ["wrangler@4.85.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260424.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260424.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-93cwt2RPb1qdcmEgPzH7ybiLN4BIKoWpscIX6SywjHrQOeIZrQk2haoc3XMLKtQTmzapxza9OuDD+kMHpsuuhg=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -1675,7 +2112,7 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], @@ -1689,7 +2126,9 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="], + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -1699,11 +2138,11 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@aws-sdk/middleware-user-agent/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="], + "@aws-sdk/middleware-user-agent/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" } }, "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g=="], - "@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="], + "@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.22", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.34", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-/rXhMXteD+BqhFd0nYprAgcZ/KtU+963uftPqd3tiFcFfooHZINXUGtOmo2SQjRVauCTNqIEzkwuSETdZFqTTA=="], - "@better-auth/core/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" } }, "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], @@ -1713,20 +2152,24 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@node-minify/core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], - "@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "@opencut/web/next": ["next@16.1.3", "", { "dependencies": { "@next/env": "16.1.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.3", "@next/swc-darwin-x64": "16.1.3", "@next/swc-linux-arm64-gnu": "16.1.3", "@next/swc-linux-arm64-musl": "16.1.3", "@next/swc-linux-x64-gnu": "16.1.3", "@next/swc-linux-x64-musl": "16.1.3", "@next/swc-win32-arm64-msvc": "16.1.3", "@next/swc-win32-x64-msvc": "16.1.3", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw=="], - "@opencut/web/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "@opencut/web/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@opennextjs/aws/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -1819,54 +2262,62 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@types/pg/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@upstash/core-analytics/@upstash/redis": ["@upstash/redis@1.35.1", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-sIMuAMU9IYbE2bkgDby8KLoQKRiBMXn0moXxqLvUmQ7VUu2CvulZLtK8O0x3WQZFvvZhU5sRC2/lOVZdGfudkA=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "cloudflare/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], - - "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "drizzle-kit/esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], - "eciesjs/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "finalhandler/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "global-agent/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + + "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.22.0-dev.20250409-89f8206ba4", "", {}, "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -1875,22 +2326,24 @@ "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "react-day-picker/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], - "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - "router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "send/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "tsx/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -1951,6 +2404,26 @@ "@node-minify/core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "@opencut/web/next/@next/env": ["@next/env@16.1.3", "", {}, "sha512-BLP14oBOvZWXgfdJf9ao+VD8O30uE+x7PaV++QtACLX329WcRSJRO5YJ+Bcvu0Q+c/lei41TjSiFf6pXqnpbQA=="], + + "@opencut/web/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CpOD3lmig6VflihVoGxiR/l5Jkjfi4uLaOR4ziriMv0YMDoF6cclI+p5t2nstM8TmaFiY6PCTBgRWB57/+LiBA=="], + + "@opencut/web/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-aF4us2JXh0zn3hNxvL1Bx3BOuh8Lcw3p3Xnurlvca/iptrDH1BrpObwkw9WZra7L7/0qB9kjlREq3hN/4x4x+Q=="], + + "@opencut/web/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8VRkcpcfBtYvhGgXAF7U3MBx6+G1lACM1XCo1JyaUr4KmAkTNP8Dv2wdMq7BI+jqRBw3zQE7c57+lmp7jCFfKA=="], + + "@opencut/web/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-UbFx69E2UP7MhzogJRMFvV9KdEn4sLGPicClwgqnLht2TEi204B71HuVfps3ymGAh0c44QRAF+ZmvZZhLLmhNg=="], + + "@opencut/web/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SzGTfTjR5e9T+sZh5zXqG/oeRQufExxBF6MssXS7HPeZFE98JDhCRZXpSyCfWrWrYrzmnw/RVhlP2AxQm+wkRQ=="], + + "@opencut/web/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-HlrDpj0v+JBIvQex1mXHq93Mht5qQmfyci+ZNwGClnAQldSfxI6h0Vupte1dSR4ueNv4q7qp5kTnmLOBIQnGow=="], + + "@opencut/web/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3gFCp83/LSduZMSIa+lBREP7+5e7FxpdBoc9QrCdmp+dapmTK9I+SLpY60Z39GDmTXSZA4huGg9WwmYbr6+WRw=="], + + "@opencut/web/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-1SZVfFT8zmMB+Oblrh5OKDvUo5mYQOkX2We6VGzpg7JUVZlqe4DYOFGKYZKTweSx1gbMixyO1jnFT4thU+nNHQ=="], + + "@opencut/web/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "@opennextjs/aws/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "@opennextjs/aws/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], @@ -2063,81 +2536,75 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "cloudflare/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], - - "cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], - - "cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], - - "cmdk/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "cmdk/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], + "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], @@ -2199,12 +2666,16 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@node-minify/core/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@node-minify/core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "@node-minify/core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@node-minify/core/glob/path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "@node-minify/core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "@opencut/web/next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "@node-minify/core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..fd56f7d0 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,96 @@ +import js from "@eslint/js"; +import next from "@next/eslint-plugin-next"; +import eslintConfigPrettier from "eslint-config-prettier/flat"; +import jsxA11y from "eslint-plugin-jsx-a11y"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import preferObjectParams from "./eslint/rules/prefer-object-params.mjs"; + +const webFiles = ["apps/web/src/**/*.{ts,tsx}"]; + +const opencutEslintPlugin = { + meta: { + name: "eslint-plugin-opencut", + version: "0.0.0", + }, + rules: { + "prefer-object-params": preferObjectParams, + }, +}; + +function scopeToWebFiles(config) { + return { + ...config, + files: webFiles, + }; +} + +export default [ + { + ignores: ["**/.next/**", "**/node_modules/**", "**/dist/**", "**/build/**"], + }, + { + files: webFiles, + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + linterOptions: { + reportUnusedDisableDirectives: "error", + }, + settings: { + react: { + version: "detect", + }, + }, + }, + scopeToWebFiles(js.configs.recommended), + ...tseslint.configs.recommended.map(scopeToWebFiles), + scopeToWebFiles(react.configs.flat.recommended), + scopeToWebFiles(react.configs.flat["jsx-runtime"]), + scopeToWebFiles(reactHooks.configs.flat["recommended-latest"]), + scopeToWebFiles(jsxA11y.flatConfigs.recommended), + scopeToWebFiles(next.configs["core-web-vitals"]), + { + files: webFiles, + plugins: { + opencut: opencutEslintPlugin, + }, + rules: { + "@typescript-eslint/no-empty-object-type": "warn", + "@typescript-eslint/no-unsafe-type-assertion": "error", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "no-empty": "warn", + "opencut/prefer-object-params": "error", + + // `react/prop-types` is for the JS-era React workflow where runtime + // `propTypes` declarations are the prop contract. In this TS-only + // scope the prop types already are the contract; the rule's only + // effect is false positives when it can't trace destructured props + // back to a `propTypes` definition that doesn't exist. + "react/prop-types": "off", + }, + }, + scopeToWebFiles(eslintConfigPrettier), +]; diff --git a/eslint/rules/__tests__/prefer-object-params.test.mjs b/eslint/rules/__tests__/prefer-object-params.test.mjs new file mode 100644 index 00000000..7f4bc947 --- /dev/null +++ b/eslint/rules/__tests__/prefer-object-params.test.mjs @@ -0,0 +1,112 @@ +import { afterAll, describe, it } from "bun:test"; +import { RuleTester } from "eslint"; +import tseslint from "typescript-eslint"; +import rule from "../prefer-object-params.mjs"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; +RuleTester.afterAll = afterAll; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, + }, +}); + +const error = { messageId: "preferObjectParams" }; + +ruleTester.run("prefer-object-params", rule, { + valid: [ + { code: "function f() {}" }, + { code: "function f(a) {}" }, + { code: "const f = (a) => a;" }, + { code: "const f = function (a) { return a; };" }, + { code: "function f({ a, b }) { return a + b; }" }, + { code: "function f({ a, b }: { a: number; b: number }) { return a + b; }" }, + + // Direct callbacks: positional args are dictated by the caller. + { code: "[1, 2, 3].reduce((acc, cur) => acc + cur, 0);" }, + { code: "arr.map((value, index) => value + index);" }, + { code: "new Promise((resolve, reject) => {});" }, + { code: "maybeFn?.((a, b) => a + b);" }, + + // Inline option-object callbacks: caller dictates the signature + // (e.g. zustand's `persist`, tanstack-query's `onSuccess`). + { code: "someAPI({ on: (a, b) => a + b });" }, + { + code: "persist((set, get) => ({}), { migrate: (state, version) => state });", + }, + { code: "new Foo({ handler: (a, b) => a + b });" }, + { code: "maybeFn?.({ handler: (a, b) => a + b });" }, + + // Deeply-nested inline option callbacks still qualify: the entire + // option tree is supplied at the call site, so the caller dictates + // every signature inside it. Real example: better-auth's + // `betterAuth({ rateLimit: { customStorage: { set: (k, v) => ... }}})`. + { code: "someAPI({ outer: { handler: (a, b) => a + b } });" }, + + // Type predicates require a positional subject. + { + code: "function isFoo(x: unknown, ctx: Ctx): x is Foo { return true; }", + }, + { + code: "const isFoo = (x: unknown, ctx: Ctx): x is Foo => true;", + }, + ], + invalid: [ + { + code: "function f(a, b) { return a + b; }", + errors: [error], + }, + { + code: "const f = (a, b) => a + b;", + errors: [error], + }, + { + code: "const f = function (a, b) { return a + b; };", + errors: [error], + }, + { + code: "function f(a, b, c) { return a + b + c; }", + errors: [error], + }, + { + code: "function formatTime(seconds: number, fps: number) { return seconds * fps; }", + errors: [error], + }, + // A callback assigned to a name and passed indirectly is still a + // signature the author controls; the carve-out is intentionally narrow + // to direct callbacks. + { + code: "const cb = (a, b) => a + b; arr.reduce(cb, 0);", + errors: [error], + }, + // Pre-declared option object: once the object is named, its callback + // signatures are the author's to evolve. + { + code: "const opts = { handler: (a, b) => a + b }; someAPI(opts);", + errors: [error], + }, + // The error must point at the function's declaration line, so a + // `// eslint-disable-next-line` placed above the declaration (the + // conventional spot) suppresses the violation even when the + // signature spans multiple lines. Reporting at a parameter row + // instead leaves the directive inert and `reportUnusedDisableDirectives` + // then flags every suppressed call site. + { + code: [ + "function f(", + "\ta: number,", + "\tb: number,", + ") { return a + b; }", + ].join("\n"), + errors: [{ messageId: "preferObjectParams", line: 1 }], + }, + ], +}); diff --git a/eslint/rules/prefer-object-params.mjs b/eslint/rules/prefer-object-params.mjs new file mode 100644 index 00000000..a5a8caae --- /dev/null +++ b/eslint/rules/prefer-object-params.mjs @@ -0,0 +1,107 @@ +export default { + meta: { + type: "suggestion", + docs: { + description: + "Prefer a single destructured object parameter over multiple positional parameters", + }, + schema: [], + messages: { + preferObjectParams: + "Use a single destructured object parameter instead of multiple positional parameters. Example: function someFunction({ name, age }: { name: string, age: number }) {}", + }, + }, + create(context) { + function isCallArgument(node) { + return ( + node && + (node.type === "CallExpression" || + node.type === "NewExpression" || + node.type === "OptionalCallExpression") + ); + } + + function isDirectCallback(node) { + const parent = node.parent; + return ( + isCallArgument(parent) && parent.arguments.includes(node) + ); + } + + // Some APIs receive callbacks via a property somewhere inside an inline + // option object (e.g. `persist(state, { migrate: (s, v) => ... })` or + // `betterAuth({ rateLimit: { customStorage: { set: (k, v) => ... }}})`). + // The caller still dictates the signature, so the same carve-out as + // `isDirectCallback` applies. Pre-declared option objects don't qualify — + // once you name the object, the signature is yours to evolve. + function isInlineOptionCallback(node) { + let current = node; + let property = current.parent; + if ( + !property || + property.type !== "Property" || + property.value !== current + ) { + return false; + } + + let objectExpression = property.parent; + while (objectExpression?.type === "ObjectExpression") { + const parent = objectExpression.parent; + if (isCallArgument(parent) && parent.arguments.includes(objectExpression)) { + return true; + } + + if ( + parent?.type !== "Property" || + parent.value !== objectExpression + ) { + return false; + } + + property = parent; + objectExpression = property.parent; + } + + return false; + } + + // Type predicates (`x is Foo`) require a positional subject by language + // rule, so the readability/evolution argument doesn't apply. AGENTS.md + // carves these out explicitly. + function isTypePredicate(node) { + return node.returnType?.typeAnnotation?.type === "TSTypePredicate"; + } + + function reportIfNeeded(node) { + if ( + node.params.length <= 1 || + isDirectCallback(node) || + isInlineOptionCallback(node) || + isTypePredicate(node) + ) { + return; + } + + // Report from the function's first line through params[1] so a + // conventional `// eslint-disable-next-line` placed above the + // declaration suppresses the violation. The directive matches + // against `loc.start.line`, which must land on the function + // declaration line — not on a later parameter row. + context.report({ + node, + loc: { + start: node.loc.start, + end: node.params[1].loc.end, + }, + messageId: "preferObjectParams", + }); + } + + return { + ArrowFunctionExpression: reportIfNeeded, + FunctionDeclaration: reportIfNeeded, + FunctionExpression: reportIfNeeded, + }; + }, +}; diff --git a/package.json b/package.json index 22504650..0e58b6bb 100644 --- a/package.json +++ b/package.json @@ -13,25 +13,36 @@ "dev:tools": "turbo run dev --filter=@opencut/tools", "dev:wasm": "cargo watch -w rust/crates -w rust/wasm/src -s 'wasm-pack build rust/wasm --target bundler --out-dir pkg'", "dev:web": "turbo run dev --filter=@opencut/web", - "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000", + "format:web": "prettier apps/web/src/services/renderer --write", "generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts", - "lint:web": "biome lint apps/web/src --max-diagnostics=1000", - "lint:web:fix": "biome lint apps/web/src --write --unsafe --max-diagnostics=1000", + "lint:web": "eslint apps/web/src --ext .ts,.tsx", + "lint:web:fix": "eslint apps/web/src --ext .ts,.tsx --fix", "preview:web": "turbo run preview --filter=@opencut/web", "publish:wasm": "bun run build:wasm && npm publish rust/wasm/pkg --access public", "start:tools": "turbo run start --filter=@opencut/tools", "test": "bun test" }, "dependencies": { - "@biomejs/biome": "^2.4.8", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "better-auth": "^1.4.15", - "next": "^16.1.3" + "next": "^16.1.3", + "opencut": ".", + "opencut-wasm": "^0.2.10" }, "devDependencies": { + "@eslint/js": "^9.39.4", + "@next/eslint-plugin-next": "^16.2.4", + "eslint": "^9.39.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.5.0", + "prettier": "^3.8.3", "turbo": "^2.8.20", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "typescript-eslint": "^8.59.0" }, "trustedDependencies": [ "@tailwindcss/oxide" diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml index 3fbfe5ba..0f2bfc56 100644 --- a/rust/wasm/Cargo.toml +++ b/rust/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencut-wasm" -version = "0.2.9" +version = "0.2.10" edition = "2024" description = "Shared video editor logic compiled to WebAssembly" repository = "https://github.com/opencut/opencut" |
